From 474899badc151a975b6e2bd300611316c3cf66c0 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 28 Apr 2026 19:13:12 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20chatops=5Frepo=20restriction=20?= =?UTF-8?q?=E2=80=94=20keep=20slash=20commands=20off=20public=20repos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why ChatOps commands (/configure-repo, /sync-all-repos, /review-pr, etc.) are currently honoured from any repo where Temper is installed. Even with allowed_command_users restricting *who* can trigger them, the bot's "Working on it…" replies and any error responses are public — sitting in whichever public repo the maintainer happened to comment in. For an org running an org-wide config sweep (especially the upcoming /sync-all-repos that migrates every repo to Repository Rulesets + applies config changes via PR), the user wants the trigger and the bot's response trail to stay in a private admin repo. ## What Opt-in `chatops_repo:` config section: chatops_repo: enabled: false repo: pulseengine/temper-ops When `enabled: true`, the issue_comment.created handler silently ignores any comment starting with `/` UNLESS it's posted in `repo`. The check fires *before* command extraction, so: - no log line emitted - no comment posted back - no public footprint at all Non-command comments (no leading `/`) are unaffected — handler proceeds through the existing no-op path. Commands posted in the designated repo work exactly as before. ## Operational pattern this unlocks 1. Create `pulseengine/temper-ops` as a **private** repo 2. Install Temper on it 3. Set `chatops_repo.enabled: true` and `repo: pulseengine/temper-ops` in `config.local.yml` 4. Trigger /sync-all-repos, /configure-repo, etc. from there 5. The bot's "Working on it…" + result replies all land in the private repo. Configuration PRs the bot opens against public repos are still public — that's the intentional asymmetry: trigger private, application public, audit trail intact. ## Test plan - [x] All 792 tests pass (was 788; +4 covering: command honoured from admin repo, command silently ignored from public repo, non-command comments unaffected, legacy default still honours every repo) - [x] eslint clean - [ ] After deploy: enable chatops_repo, comment /configure-repo on any public pulseengine repo — should be a complete no-op (no working comment, no error). Comment from temper-ops — should work normally. ## Risk & rollout - Risk: low. Opt-in via config; default behaviour unchanged. - Rollout: self-update on merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/integration/app.test.js | 73 +++++++++++++++++++++++++++++++ config.yml | 10 +++++ src/app.js | 16 ++++++- src/config.js | 4 ++ src/schema.js | 14 ++++++ 5 files changed, 116 insertions(+), 1 deletion(-) diff --git a/__tests__/integration/app.test.js b/__tests__/integration/app.test.js index 2b68486..c6556f7 100644 --- a/__tests__/integration/app.test.js +++ b/__tests__/integration/app.test.js @@ -252,6 +252,79 @@ describe('app', () => { _setConfigForTesting({}); }); + // ========================================================================= + // chatops_repo enforcement — slash commands only honoured in admin repo + // ========================================================================= + describe('issue_comment.created chatops_repo gate', () => { + function ctx(repoFullName, body) { + const [o, r] = repoFullName.split('/'); + const octokit = createMockOctokit(); + return { + id: 'delivery-chatops', + log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + octokit, + payload: { + comment: { body }, + repository: { full_name: repoFullName, name: r, owner: { login: o } }, + sender: { login: 'avrabe' }, + issue: { number: 1 } + } + }; + } + + it('honours commands from the designated chatops repo', async () => { + _setConfigForTesting({ + chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' }, + allowed_command_users: ['avrabe'] + }); + const { handlers } = setupApp(); + configureRepository.mockResolvedValue({ success: true }); + + await handlers['issue_comment.created'](ctx('pulseengine/temper-ops', '/configure-repo')); + + expect(configureRepository).toHaveBeenCalled(); + }); + + it('silently ignores slash commands from any other repo', async () => { + _setConfigForTesting({ + chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' }, + allowed_command_users: ['avrabe'] + }); + const { handlers } = setupApp(); + + const c = ctx('pulseengine/some-public-repo', '/configure-repo'); + await handlers['issue_comment.created'](c); + + expect(configureRepository).not.toHaveBeenCalled(); + // Critically: no comment posted back. Silence in public repos. + expect(c.octokit.issues.createComment).not.toHaveBeenCalled(); + }); + + it('does not gate non-command comments (only / triggers are gated)', async () => { + _setConfigForTesting({ + chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' } + }); + const { handlers } = setupApp(); + + const c = ctx('pulseengine/some-public-repo', 'just chatting, no command'); + await handlers['issue_comment.created'](c); + // Plain comments from any repo are still silently dropped (no command + // matched), and no error is raised. + expect(c.octokit.issues.createComment).not.toHaveBeenCalled(); + }); + + it('falls back to honouring all repos when chatops_repo is disabled (legacy default)', async () => { + _setConfigForTesting({ + allowed_command_users: ['avrabe'] + }); + const { handlers } = setupApp(); + configureRepository.mockResolvedValue({ success: true }); + + await handlers['issue_comment.created'](ctx('pulseengine/anywhere', '/configure-repo')); + expect(configureRepository).toHaveBeenCalled(); + }); + }); + // ========================================================================= // issues.opened — controller repo / issue-driven provisioning // ========================================================================= diff --git a/config.yml b/config.yml index d201e50..d6da25f 100644 --- a/config.yml +++ b/config.yml @@ -63,6 +63,16 @@ controller_repo: # Reaction approvers must add to authorise provisioning. approval_reaction: "+1" +# ChatOps surface restriction. When enabled, ALL slash commands +# (/configure-repo, /sync-all-repos, /review-pr, etc.) are silently ignored +# from any repo other than the designated `repo`. Combined with making +# `repo` private, the entire command-and-control conversation stays out of +# public view. Bot-initiated configuration PRs in public repos are still +# public — this only hides the *triggers*. +chatops_repo: + enabled: false + repo: pulseengine/temper-ops + # Configuration changes (dependabot.yml, templates, CODEOWNERS) are applied via # pull request rather than direct push. Required because branch protection # (PR #19) blocks direct commits to default branches. diff --git a/src/app.js b/src/app.js index 6d2984f..f98074b 100644 --- a/src/app.js +++ b/src/app.js @@ -2,7 +2,7 @@ import path from 'path'; import fs from 'node:fs'; import { fileURLToPath } from 'url'; import { createDashboardHandler, DEPLOY_SHA } from './dashboard.js'; -import { getConfig, getControllerRepoConfig } from './config.js'; +import { getConfig, getControllerRepoConfig, getChatopsRepoConfig } from './config.js'; import { getLogger, setLogger } from './logger.js'; import { configureRepository } from './repository.js'; import { @@ -310,6 +310,20 @@ function registerApp(app, options = {}) { const commandBody = comment.body.trim(); const owner = repository.owner.login; const repo = repository.name; + + // ChatOps surface restriction: when enabled, slash commands are only + // honoured in the designated admin repo. Comments from any other repo + // are silently ignored — no log, no reply, no bot footprint in public + // repos. The actual repo configuration changes still happen in their + // target repos; this only restricts where the *triggers* can come from. + const chatopsCfg = getChatopsRepoConfig(); + if (chatopsCfg?.enabled && chatopsCfg?.repo && commandBody.startsWith('/')) { + const fullName = `${owner}/${repo}`; + if (fullName !== chatopsCfg.repo) { + if (deliveryId) markProcessed(deliveryId); + return; + } + } const issueNumber = context.payload.issue.number; const senderLogin = sender.login; diff --git a/src/config.js b/src/config.js index df2dd39..f562382 100644 --- a/src/config.js +++ b/src/config.js @@ -160,6 +160,10 @@ export function getControllerRepoConfig() { return config?.controller_repo || { enabled: false }; } +export function getChatopsRepoConfig() { + return config?.chatops_repo || { enabled: false }; +} + export function getRequiredSignaturesFlag(protectionConfig = {}) { if (typeof protectionConfig.require_signed_commits === 'boolean') { return protectionConfig.require_signed_commits; diff --git a/src/schema.js b/src/schema.js index c2a1d2b..5d38aeb 100644 --- a/src/schema.js +++ b/src/schema.js @@ -171,6 +171,20 @@ export function validateConfig(config) { } } + if (config.chatops_repo !== undefined) { + const cr = config.chatops_repo; + if (typeof cr !== 'object' || cr === null) { + errors.push('chatops_repo must be an object'); + } else { + if (cr.enabled !== undefined && typeof cr.enabled !== 'boolean') { + errors.push('chatops_repo.enabled must be a boolean'); + } + if (cr.enabled && (typeof cr.repo !== 'string' || !cr.repo.includes('/'))) { + errors.push('chatops_repo.repo must be "owner/repo" when enabled'); + } + } + } + if (config.rivet_oracle !== undefined) { const ro = config.rivet_oracle; if (typeof ro !== 'object' || ro === null) { From 3754b5d6d0d3931a0c3f5fa818a180a88e42d708 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 28 Apr 2026 19:42:30 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20ChatOps=20issue=20forms=20=E2=80=94?= =?UTF-8?q?=20click-and-submit=20instead=20of=20slash=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why After the chatops_repo restriction (PR #32), the user wanted "issue forms where I could just ask for things — e.g. redo for repository all, … then it is super easy". Filling a form > remembering slash command syntax, especially for less-frequent operations. ## What ### `docs/temper-ops-template/.github/ISSUE_TEMPLATE/` Four ready-to-copy issue forms (each labeled `chatops:`): - `sync-all-repos.yml` — "Sync all repos" (no fields, just a confirm) - `configure-repo.yml` — "Configure single repo" (Repository field) - `analyze-org.yml` — "Org analysis report" (no fields) - `review-pr.yml` — "Review pull request" (Repository + PR number) ### `docs/temper-ops-setup.md` Operator guide: create the private repo, install the bot, set `chatops_repo` config, copy the issue templates, done. ### `src/app.js` — `handleChatopsIssue` The existing `issues.opened` handler grew a new branch BEFORE the controller_repo provisioning path: if (chatops_repo.enabled && fullName === chatops_repo.repo) { const label = issue.labels.find(l => l.name.startsWith('chatops:')); if (label) { handleChatopsIssue(...) // dispatch by label return; } } // ... existing controller_repo provisioning falls through unchanged Each chatops: dispatches to the same underlying function the slash command would have called (synchronizeAllRepositories, configureRepository, generateOrganizationAnalysisReport, reviewPullRequest), replies on the source issue, and closes it on success. The closed issue + form fields + bot replies = the audit trail. ## Operating model With chatops_repo + temper-ops + these forms in place: Open issue in temper-ops → choose template → fill (or none) → submit → bot acknowledges → bot does the work → bot replies with result → bot closes the issue. No slash command syntax to remember. No public-repo footprint. Issue remains as the audit record. ## Test plan - [x] All 799 tests pass (was 792 — added 7 covering each chatops label dispatch, missing-field rejection, non-allowed-user rejection, and fall-through to controller_repo when no chatops label is present) - [x] eslint clean - [ ] After deploy + creating temper-ops + copying templates: open the "Sync all repos" form, submit, observe bot's progress + result replies, observe issue closure on success. ## Risk & rollout - Risk: low. The new code path is gated by chatops_repo.enabled (default false) AND label-prefix-match. Until the operator copies the issue templates into temper-ops, no chatops labels ever fire. - Rollout: self-update on merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/integration/app.test.js | 140 ++++++++++++++++++ docs/temper-ops-setup.md | 105 +++++++++++++ .../.github/ISSUE_TEMPLATE/analyze-org.yml | 13 ++ .../.github/ISSUE_TEMPLATE/configure-repo.yml | 17 +++ .../.github/ISSUE_TEMPLATE/review-pr.yml | 25 ++++ .../.github/ISSUE_TEMPLATE/sync-all-repos.yml | 26 ++++ docs/temper-ops-template/README.md | 16 ++ src/app.js | 135 ++++++++++++++++- 8 files changed, 475 insertions(+), 2 deletions(-) create mode 100644 docs/temper-ops-setup.md create mode 100644 docs/temper-ops-template/.github/ISSUE_TEMPLATE/analyze-org.yml create mode 100644 docs/temper-ops-template/.github/ISSUE_TEMPLATE/configure-repo.yml create mode 100644 docs/temper-ops-template/.github/ISSUE_TEMPLATE/review-pr.yml create mode 100644 docs/temper-ops-template/.github/ISSUE_TEMPLATE/sync-all-repos.yml create mode 100644 docs/temper-ops-template/README.md diff --git a/__tests__/integration/app.test.js b/__tests__/integration/app.test.js index c6556f7..da1cb5a 100644 --- a/__tests__/integration/app.test.js +++ b/__tests__/integration/app.test.js @@ -411,6 +411,146 @@ describe('app', () => { }); }); + // ========================================================================= + // issues.opened — chatops_repo issue forms + // ========================================================================= + describe('chatops issue forms (issues.opened in chatops_repo)', () => { + function ctx(label, body = '', overrides = {}) { + const octokit = createMockOctokit(); + return { + id: 'delivery-chatops-form', + log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + octokit, + payload: { + issue: { + number: 5, + body, + labels: label ? [{ name: label }] : [], + ...(overrides.issue || {}) + }, + repository: { + full_name: 'pulseengine/temper-ops', + name: 'temper-ops', + owner: { login: 'pulseengine' } + }, + sender: { login: 'avrabe', ...(overrides.sender || {}) } + } + }; + } + + beforeEach(() => { + _setConfigForTesting({ + organization: 'pulseengine', + chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' }, + allowed_command_users: ['avrabe'] + }); + }); + + it('chatops:sync-all-repos triggers org-wide sync, replies, closes issue', async () => { + synchronizeAllRepositories.mockResolvedValue({ success: true, repositoriesProcessed: 13 }); + const { handlers } = setupApp(); + const c = ctx('chatops:sync-all-repos'); + + await handlers['issues.opened'](c); + + expect(synchronizeAllRepositories).toHaveBeenCalledWith(c.octokit, 'pulseengine'); + // Two comments: starting + result + expect(c.octokit.issues.createComment).toHaveBeenCalledTimes(2); + // Closed via PATCH route + expect(c.octokit.request).toHaveBeenCalledWith( + 'PATCH /repos/{owner}/{repo}/issues/{issue_number}', + expect.objectContaining({ state: 'closed' }) + ); + }); + + it('chatops:configure-repo parses Repository field, calls configureRepository', async () => { + configureRepository.mockResolvedValue({ success: true }); + const { handlers } = setupApp(); + const body = '### Repository\n\npulseengine/spar'; + const c = ctx('chatops:configure-repo', body); + + await handlers['issues.opened'](c); + + // 1st request = GET repo (resolves repoData), 2nd = close issue PATCH + expect(c.octokit.request).toHaveBeenCalledWith( + 'GET /repos/{owner}/{repo}', + expect.objectContaining({ owner: 'pulseengine', repo: 'spar' }) + ); + expect(configureRepository).toHaveBeenCalled(); + }); + + it('chatops:configure-repo rejects when Repository field is missing', async () => { + const { handlers } = setupApp(); + const c = ctx('chatops:configure-repo', '### Some other field\n\nx'); + + await handlers['issues.opened'](c); + + expect(configureRepository).not.toHaveBeenCalled(); + const body = c.octokit.issues.createComment.mock.calls[0][0].body; + expect(body).toMatch(/Missing field/i); + }); + + it('chatops:analyze-org generates report as a separate issue', async () => { + generateOrganizationAnalysisReport.mockResolvedValue('# Analysis'); + const { handlers } = setupApp(); + const c = ctx('chatops:analyze-org'); + + await handlers['issues.opened'](c); + + expect(generateOrganizationAnalysisReport).toHaveBeenCalled(); + // Created a new analysis report issue (not just a comment) + expect(c.octokit.issues.create).toHaveBeenCalledWith( + expect.objectContaining({ title: expect.stringContaining('Organization Analysis Report') }) + ); + }); + + it('chatops:review-pr requires Repository AND PR number', async () => { + reviewPullRequest.mockResolvedValue({ success: true }); + const { handlers } = setupApp(); + const body = '### Repository\n\npulseengine/rivet\n\n### PR number\n\n213'; + const c = ctx('chatops:review-pr', body); + + await handlers['issues.opened'](c); + + expect(reviewPullRequest).toHaveBeenCalledWith(c.octokit, 'pulseengine', 'rivet', 213); + }); + + it('rejects ChatOps from a non-allowed user', async () => { + _setConfigForTesting({ + organization: 'pulseengine', + chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' }, + allowed_command_users: ['someone-else'] + }); + const { handlers } = setupApp(); + const c = ctx('chatops:sync-all-repos'); + + await handlers['issues.opened'](c); + + expect(synchronizeAllRepositories).not.toHaveBeenCalled(); + const body = c.octokit.issues.createComment.mock.calls[0][0].body; + expect(body).toMatch(/not authorised/i); + }); + + it('falls through to controller_repo provisioning when no chatops label', async () => { + _setConfigForTesting({ + organization: 'pulseengine', + chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' }, + controller_repo: { enabled: true, repo: 'pulseengine/temper-ops', label: 'repo-request' } + }); + const { handlers } = setupApp(); + const c = ctx('repo-request', '### Repository name\n\nnew-svc\n\n### Visibility\n\nprivate'); + + await handlers['issues.opened'](c); + + // Provisioning path was reached. In the test env getEnqueueTask returns + // null (no SQLite store), so the bot posts the offline-fallback message; + // in production it would be "Request accepted...". Either confirms the + // controller_repo handler ran (i.e., we fell through past chatops_repo). + const body = c.octokit.issues.createComment.mock.calls[0][0].body; + expect(body).toMatch(/Request accepted|task store offline/i); + }); + }); + // ========================================================================= // mapLegacyEnvVars // ========================================================================= diff --git a/docs/temper-ops-setup.md b/docs/temper-ops-setup.md new file mode 100644 index 0000000..3b0212b --- /dev/null +++ b/docs/temper-ops-setup.md @@ -0,0 +1,105 @@ +# temper-ops setup + +The **temper-ops** repo is the private command-and-control surface for Temper. +It's where you trigger sweeps, request reviews, and invoke ChatOps without +leaving a footprint on public repos. + +## Why a separate (private) repo + +Temper's slash commands (`/sync-all-repos`, `/configure-repo`, `/review-pr`, +etc.) are honoured wherever the bot is installed and the commenter is in +`allowed_command_users`. By default, that means a maintainer commenting +`/sync-all-repos` in *any* public repo lands the bot's "Working on it…" +reply, the result, and any error trace in that public repo's issue thread. + +Setting `chatops_repo.enabled: true` in `config.local.yml` constrains the +trigger surface to a single repo. Combined with making that repo private, +the conversation between you and the bot stays out of public view — +while bot-initiated configuration PRs against public repos continue to be +public, as they should be. + +## One-time setup + +### 1. Create the repo + +```bash +gh repo create pulseengine/temper-ops --private --description "Temper bot ChatOps surface" +``` + +(or via the GitHub UI — anything works, as long as it's private.) + +### 2. Install the Temper App on the new repo + +In the Temper GitHub App settings, add `pulseengine/temper-ops` to the +list of repositories it can access. The bot needs the same permissions +it has on every other repo (Contents, Issues, PRs, Members read). + +### 3. Wire the bot to the new repo + +On the deployment host (`/opt/temper/config.local.yml`): + +```yaml +chatops_repo: + enabled: true + repo: pulseengine/temper-ops +``` + +Then `pm2 restart temper`. From this point onwards, slash commands posted +in any *other* repo are silently dropped. + +### 4. Copy the issue forms + +The bundled templates under `docs/temper-ops-template/` give you forms for +the four most common operations: + +```bash +git clone git@github.com:pulseengine/temper-ops.git +cd temper-ops +mkdir -p .github/ISSUE_TEMPLATE +cp -r ../temper/docs/temper-ops-template/.github/ISSUE_TEMPLATE/*.yml \ + .github/ISSUE_TEMPLATE/ +git add .github && git commit -m "chore: add Temper ChatOps issue forms" && git push +``` + +After that, opening a new issue in temper-ops shows a chooser with the +forms. Submit one and the bot does the rest. + +## Forms shipped + +| Form | What it does | Bot behaviour | +|---|---|---| +| **Sync all repos** | Re-runs the full org configuration sweep | bot replies with progress, then result, then closes the issue | +| **Configure single repo** | Re-applies standard config to one repo | requires `Repository` field; bot configures and replies | +| **Org analysis report** | Generates an org-wide configuration report | bot creates a separate report issue and links it | +| **Review pull request** | Triggers AI review on a specific PR | requires `Repository` and `PR number`; bot posts the review on the target PR, replies on the temper-ops issue with the link | + +Each form's first label is `chatops:` — that's what the bot uses +to dispatch. + +## Operating notes + +- **Issues stay as the audit trail.** The bot replies on the source issue + and closes it on success. The closed issue with the form fields is the + record of what was triggered, by whom, when, and what the bot did. +- **Slash commands still work** as comments inside temper-ops, alongside + the issue forms. The two paths are equivalent — pick whichever feels + natural. +- **`/review-pr` is also still available** on the target PR itself for + one-off in-context use; the form is just the no-leave-this-repo path. +- **Bot replies are private** because they're in temper-ops. Bot's actual + configuration PRs against public repos stay public. + +## Adding more forms + +To wire a new ChatOps command into the form workflow: + +1. Add `.yml` under `docs/temper-ops-template/.github/ISSUE_TEMPLATE/` + with `labels: ["chatops:"]`. +2. Add a case for `chatops:` in `handleChatopsIssue` in + `src/app.js`. +3. The form's body fields are accessible via `parseIssueFormBody(issue.body)` + — see the existing handler for reference. +4. Copy the new template into the actual temper-ops repo. + +Forms are *only* read in the temper-ops repo (or whatever you configure +`chatops_repo.repo` to be). They have no effect in other repos. diff --git a/docs/temper-ops-template/.github/ISSUE_TEMPLATE/analyze-org.yml b/docs/temper-ops-template/.github/ISSUE_TEMPLATE/analyze-org.yml new file mode 100644 index 0000000..1438133 --- /dev/null +++ b/docs/temper-ops-template/.github/ISSUE_TEMPLATE/analyze-org.yml @@ -0,0 +1,13 @@ +name: Org analysis report +description: Generate an organization-wide configuration analysis report. +title: "Analyze: org" +labels: ["chatops:analyze-org"] +body: + - type: markdown + attributes: + value: | + Triggers `/analyze-org`. The bot walks every non-fork, non-archived repo and builds a report covering merge settings, branch protection / rulesets, dependabot status, and label coverage. + + The report is posted as a **new issue** in this repo (`temper-ops`). This issue gets a link to it once the analysis is done. + + **No fields required — just submit.** diff --git a/docs/temper-ops-template/.github/ISSUE_TEMPLATE/configure-repo.yml b/docs/temper-ops-template/.github/ISSUE_TEMPLATE/configure-repo.yml new file mode 100644 index 0000000..9979ac3 --- /dev/null +++ b/docs/temper-ops-template/.github/ISSUE_TEMPLATE/configure-repo.yml @@ -0,0 +1,17 @@ +name: Configure single repo +description: Re-apply the standard org configuration to one specific repository. +title: "Configure: " +labels: ["chatops:configure-repo"] +body: + - type: markdown + attributes: + value: | + Triggers `/configure-repo` against the named repository. Useful when one repo has drifted from the standard but you don't want to sweep the whole org. + - type: input + id: repository + attributes: + label: Repository + description: "`owner/repo`, or just `repo` if it's in this org." + placeholder: pulseengine/spar + validations: + required: true diff --git a/docs/temper-ops-template/.github/ISSUE_TEMPLATE/review-pr.yml b/docs/temper-ops-template/.github/ISSUE_TEMPLATE/review-pr.yml new file mode 100644 index 0000000..c0cf237 --- /dev/null +++ b/docs/temper-ops-template/.github/ISSUE_TEMPLATE/review-pr.yml @@ -0,0 +1,25 @@ +name: Review pull request +description: Trigger the AI code review on a specific pull request. +title: "Review: #" +labels: ["chatops:review-pr"] +body: + - type: markdown + attributes: + value: | + Triggers `/review-pr` against the named PR. The bot fetches the diff, runs `rivet validate`/`rivet impact` if the repo is rivet-instrumented, calls the local AI model with the strict-JSON contract, and posts the review *on the target PR* (not here). + + This issue gets a confirmation comment with the link. + - type: input + id: repository + attributes: + label: Repository + placeholder: pulseengine/rivet + validations: + required: true + - type: input + id: pr_number + attributes: + label: PR number + placeholder: "213" + validations: + required: true diff --git a/docs/temper-ops-template/.github/ISSUE_TEMPLATE/sync-all-repos.yml b/docs/temper-ops-template/.github/ISSUE_TEMPLATE/sync-all-repos.yml new file mode 100644 index 0000000..1d6b54c --- /dev/null +++ b/docs/temper-ops-template/.github/ISSUE_TEMPLATE/sync-all-repos.yml @@ -0,0 +1,26 @@ +name: Sync all repos +description: Re-run org-wide configuration sync (rulesets, labels, dependabot, templates) across every repo in the org. +title: "Sync: all repos" +labels: ["chatops:sync-all-repos"] +body: + - type: markdown + attributes: + value: | + Triggers `/sync-all-repos`. Temper will walk every non-archived repo in the organization and apply the standard configuration: + + - **Rulesets** for the default branch (modern replacement for legacy branch protection) + - **Merge settings** (rebase-only by default) + - **Issue labels** synchronized to the org standard set + - **Templates and CODEOWNERS** committed to `.github/` + - **Dependabot configuration** generated from detected ecosystems + + Bot's progress will be posted as comments on this issue. The issue is closed automatically when the sync finishes. + + **No fields required — just submit.** + - type: checkboxes + id: ack + attributes: + label: Confirm + options: + - label: I understand this will open multiple bot PRs across the organisation. + required: true diff --git a/docs/temper-ops-template/README.md b/docs/temper-ops-template/README.md new file mode 100644 index 0000000..462284d --- /dev/null +++ b/docs/temper-ops-template/README.md @@ -0,0 +1,16 @@ +# temper-ops template + +Starter content for the Temper ChatOps admin repo. Copy `.github/` from +this directory into your `pulseengine/temper-ops` (or whatever +`chatops_repo.repo` is configured to in `config.local.yml`). + +```bash +cp -r .github/ISSUE_TEMPLATE/*.yml /path/to/temper-ops/.github/ISSUE_TEMPLATE/ +``` + +After commit + push, opening a new issue in temper-ops will show the +chooser with all the forms. The Temper bot recognises any issue whose +first label starts with `chatops:` and dispatches the corresponding +command. + +See `../temper-ops-setup.md` for the full setup walkthrough. diff --git a/src/app.js b/src/app.js index f98074b..dbe9bdf 100644 --- a/src/app.js +++ b/src/app.js @@ -66,6 +66,121 @@ const issueOps = { octokit.request('POST /repos/{owner}/{repo}/issues', args) }; +/** + * Dispatch a chatops: issue form to the equivalent action. + * Called only when the issue lives in the configured `chatops_repo.repo` + * and carries a label of the form `chatops:`. + * + * Replies to the source issue and (on success) closes it. The issue + * therefore *is* the audit trail: form fields + bot replies + close + * reason, all in one place. + */ +async function handleChatopsIssue(context, label, sender, issue, repository) { + const config = getConfig(); + const owner = repository.owner.login; + const repo = repository.name; + const issueNumber = issue.number; + const targetOrg = config?.organization || owner; + + const allowedUsers = config?.allowed_command_users || []; + if (allowedUsers.length > 0 && !allowedUsers.includes(sender?.login)) { + await issueOps.createComment(context.octokit, { + owner, repo, issue_number: issueNumber, + body: `❌ You are not authorised to trigger ChatOps. Allowed: ${allowedUsers.join(', ')}.` + }); + return; + } + + const commentBack = (body) => + issueOps.createComment(context.octokit, { owner, repo, issue_number: issueNumber, body }); + + const closeIssue = () => + context.octokit.request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', { + owner, repo, issue_number: issueNumber, state: 'closed', state_reason: 'completed' + }); + + const command = label.replace(/^chatops:/, ''); + + switch (command) { + case 'sync-all-repos': { + await commentBack('🔄 Starting org-wide sync — this can take a few minutes.'); + const result = await synchronizeAllRepositories(context.octokit, targetOrg); + await commentBack(result.success + ? `✅ Synchronized ${result.repositoriesProcessed} repositories.` + : `❌ Sync failed: ${result.error}`); + if (result.success) await closeIssue(); + return; + } + + case 'configure-repo': { + const fields = parseIssueFormBody(issue.body || ''); + const repoSpec = fields.repository || fields.repo || fields['repository (owner/repo or just repo name in this org)']; + if (!repoSpec) { + await commentBack('❌ Missing field "Repository". Please re-open the issue with a value.'); + return; + } + const [specOwner, specRepo] = repoSpec.includes('/') + ? repoSpec.split('/') + : [targetOrg, repoSpec]; + const repoData = await context.octokit.request('GET /repos/{owner}/{repo}', { + owner: specOwner, repo: specRepo + }).catch((err) => ({ error: err })); + if (repoData.error) { + await commentBack(`❌ Could not access ${specOwner}/${specRepo}: ${repoData.error.message}`); + return; + } + await commentBack(`🔄 Configuring ${specOwner}/${specRepo}…`); + const result = await configureRepository(context.octokit, repoData.data, undefined, { + enqueueTask: getEnqueueTask() + }); + await commentBack(result.success + ? `✅ Configured ${specOwner}/${specRepo}.` + : `❌ Failed: ${result.error}`); + if (result.success) await closeIssue(); + return; + } + + case 'analyze-org': { + await commentBack('🔄 Generating org analysis report…'); + const reportBody = await generateOrganizationAnalysisReport(context.octokit, targetOrg); + const reportIssue = await issueOps.createIssue(context.octokit, { + owner, repo, + title: `Organization Analysis Report - ${new Date().toISOString().split('T')[0]}`, + body: reportBody, + labels: ['analysis', 'report', 'automation'] + }); + await commentBack(`✅ Report posted as issue #${reportIssue.data.number}`); + await closeIssue(); + return; + } + + case 'review-pr': { + const fields = parseIssueFormBody(issue.body || ''); + const repoSpec = fields.repository || fields.repo; + const prRaw = fields['pr number'] || fields.pr; + const prNum = parseInt(prRaw, 10); + if (!repoSpec || !prNum) { + await commentBack('❌ Need both "Repository" and "PR number".'); + return; + } + const [specOwner, specRepo] = repoSpec.includes('/') + ? repoSpec.split('/') + : [targetOrg, repoSpec]; + await commentBack(`🔄 Triggering AI review on ${specOwner}/${specRepo}#${prNum}…`); + const result = await reviewPullRequest(context.octokit, specOwner, specRepo, prNum); + await commentBack(result.success + ? `✅ Review posted on https://github.com/${specOwner}/${specRepo}/pull/${prNum}.` + : `❌ Review failed: ${result.error}`); + if (result.success) await closeIssue(); + return; + } + + default: { + await commentBack(`❌ Unknown ChatOps label: \`${label}\`.`); + } + } +} + function applySecurityHeaders(res) { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); @@ -214,14 +329,30 @@ function registerApp(app, options = {}) { const deliveryId = context.id; if (deliveryId && isProcessed(deliveryId)) return; + const { issue, repository, sender } = context.payload; + const fullName = repository.full_name; + + // ChatOps issue forms in the admin repo: a `chatops:` label on + // a newly-opened issue triggers the corresponding ChatOps action, + // bot replies on the issue, optionally closes it. + const chatopsCfg = getChatopsRepoConfig(); + if (chatopsCfg?.enabled && fullName === chatopsCfg.repo) { + const chatopsLabel = (issue.labels || []) + .map((l) => l?.name) + .find((n) => typeof n === 'string' && n.startsWith('chatops:')); + if (chatopsLabel) { + await handleChatopsIssue(context, chatopsLabel, sender, issue, repository); + if (deliveryId) markProcessed(deliveryId); + return; + } + } + const ctrl = getControllerRepoConfig(); if (!ctrl?.enabled) { if (deliveryId) markProcessed(deliveryId); return; } - const { issue, repository } = context.payload; - const fullName = repository.full_name; if (fullName !== ctrl.repo) { if (deliveryId) markProcessed(deliveryId); return;