Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions __tests__/integration/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =========================================================================
Expand Down Expand Up @@ -338,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
// =========================================================================
Expand Down
10 changes: 10 additions & 0 deletions config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
105 changes: 105 additions & 0 deletions docs/temper-ops-setup.md
Original file line number Diff line number Diff line change
@@ -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:<command>` — 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 `<command>.yml` under `docs/temper-ops-template/.github/ISSUE_TEMPLATE/`
with `labels: ["chatops:<your-command>"]`.
2. Add a case for `chatops:<your-command>` 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.
13 changes: 13 additions & 0 deletions docs/temper-ops-template/.github/ISSUE_TEMPLATE/analyze-org.yml
Original file line number Diff line number Diff line change
@@ -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.**
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Configure single repo
description: Re-apply the standard org configuration to one specific repository.
title: "Configure: <repo-name>"
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
Loading
Loading