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
1 change: 1 addition & 0 deletions changelog.d/1044.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for JIRA cloud secure webhooks. See the [documentation](https://matrix-org.github.io/matrix-hookshot/latest/setup/jira.html) for more information.
18 changes: 13 additions & 5 deletions docs/setup/jira.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@

## Adding a webhook to a JIRA Instance

This should be done for the JIRA instance you wish to bridge. The setup steps are the same for both On-Prem and Cloud.
This should be done for the JIRA instance you wish to bridge. The setup steps vary for Cloud and Enterprise (on-premise).

### Cloud

See https://support.atlassian.com/jira-cloud-administration/docs/manage-webhooks/ for documentation on how to setup webhooks.

Hookshot **requires** that you use a secret. Please copy the generated secret value to you config (seen below).


### Enterprise

You need to go to the `WebHooks` configuration page under Settings > System.
Note that this may require administrative access to the JIRA instance.

Next, add a webhook that points to `/` on the public webhooks address for hookshot. You should also include a
Next, add a webhook that points to `/` on the public webhooks address for hookshot. You must also include a
secret value by appending `?secret=your-webhook-secret`. The secret value can be anything, but should
be reasonably secure and should also be stored in the `config.yml` file.

Ensure that you enable all the events that you wish to be bridged.

For both, ensure that you enable all the events that you wish to be bridged.

## Configuration

Expand All @@ -21,7 +29,7 @@ You can now set some configuration in the bridge `config.yml`:
```yaml
jira:
webhook:
secret: some-secret
secret: your-webhook-secret
oauth:
... # See below
```
Expand Down
13 changes: 2 additions & 11 deletions spec/github.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GitHubRepoConnection, GitHubRepoConnectionState } from "../src/Connecti
import { MessageEventContent } from "matrix-bot-sdk";
import { getBridgeApi } from "./util/bridge-api";
import { Server, createServer } from "http";
import { waitFor } from "./util/helpers";

describe('GitHub', () => {
let testEnv: E2ETestEnv;
Expand Down Expand Up @@ -72,17 +73,7 @@ describe('GitHub', () => {
} satisfies GitHubRepoConnectionState);

// Wait for connection to be accepted.
await new Promise<void>(r => {
let interval: NodeJS.Timeout;
interval = setInterval(() => {
bridgeApi.getConnectionsForRoom(testRoomId).then(conns => {
if (conns.length > 0) {
clearInterval(interval);
r();
}
})
}, 500);
});
await waitFor(async () => (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1);

const webhookNotice = user.waitForRoomEvent<MessageEventContent>({
eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId
Expand Down
161 changes: 161 additions & 0 deletions spec/jira.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test";
import { describe, expect, beforeAll, afterAll, test } from "vitest"
import { createHmac, randomUUID } from "crypto";
import { JiraProjectConnection, JiraProjectConnectionState } from "../src/Connections";
import { MessageEventContent } from "matrix-bot-sdk";
import { JiraGrantChecker } from "../src/jira/GrantChecker";
import { getBridgeApi } from "./util/bridge-api";
import { waitFor } from "./util/helpers";

const JIRA_PAYLOAD = {
"timestamp": 1745506426948,
"webhookEvent": "jira:issue_created",
"issue_event_type_name": "issue_created",
"user": {
"accountId": "1234567890",
"displayName": "Test User",
},
"issue": {
"id": "10007",
"self": "https://example.org/TP-8",
"key": "TP-8",
"fields": {
"statuscategorychangedate": "2025-04-24T15:53:47.084+0100",
"issuetype": {
"id": "10001",
"name": "Task",
},
"components": [],
"timespent": null,
"timeoriginalestimate": null,
"project": {
"self": "https://example.org/my/test/project/TP",
"key": "TP",
"id": "12345",
name: "Test Project",
projectTypeKey: "project-type-key",
simplified: false,
avatarUrls: {}
},
"description": null,
"summary": "Test issue",
"lastViewed": null,
"creator": {
"accountId": "1234567890",
"displayName": "Test User",
"self": "https://example.org/user/1234567890",
avatarUrls: {},
active: true,
timeZone: "UTC",
accountType: "atlassian",
},
"subtasks": [],
"created": "2025-04-24T15:53:46.821+0100",
"reporter": {
"accountId": "1234567890",
"displayName": "Test User",
},
"labels": [],
"environment": null,
"timeestimate": null,
"aggregatetimeoriginalestimate": null,
"versions": [],
"duedate": null,
"progress": {
"progress": 0,
"total": 0
},
"issuelinks": [],
"assignee": null,
"updated": "2025-04-24T15:53:46.821+0100",
"status": {
"name": "To Do",
"id": "10000",
},
"priority": { }
}
},
};


describe('JIRA', () => {
let testEnv: E2ETestEnv;
const webhooksPort = 9500 + E2ETestEnv.workerId;

beforeAll(async () => {
testEnv = await E2ETestEnv.createTestEnv({
matrixLocalparts: ['user'],
config: {
jira: {
webhook: {
secret: randomUUID(),
},
},
widgets: {
publicUrl: `http://localhost:${webhooksPort}`
},
listeners: [{
port: webhooksPort,
bindAddress: '0.0.0.0',
// Bind to the SAME listener to ensure we don't have conflicts.
resources: ['webhooks', 'widgets'],
}],
}
});
await testEnv.setUp();
}, E2ESetupTestTimeout);

afterAll(() => {
console.log('tear down');
return testEnv?.tearDown();
});

test('should be able to handle a JIRA event', async () => {
const user = testEnv.getUser('user');
const bridgeApi = await getBridgeApi(testEnv.opts.config?.widgets?.publicUrl!, user);
const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] });
await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50);
const jiraURL = JIRA_PAYLOAD.issue.fields.project.self;
// Pre-grant connection to allow us to bypass the oauth dance.
await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId });
const granter = new JiraGrantChecker(testEnv.app.appservice, null as any);
await granter.grantConnection(testRoomId, {
url: jiraURL,
});

// "Create" a JIRA connection.
await testEnv.app.appservice.botClient.sendStateEvent(testRoomId, JiraProjectConnection.CanonicalEventType, jiraURL, {
url: jiraURL,
} satisfies JiraProjectConnectionState);

await waitFor(async () => (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1);

const webhookNotice = user.waitForRoomEvent<MessageEventContent>({
eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId
});

const webhookPayload = JSON.stringify(JIRA_PAYLOAD);

const hmac = createHmac('sha256', testEnv.opts.config?.jira?.webhook.secret!);
hmac.write(webhookPayload);
hmac.end();

// Send a webhook
const req = await fetch(`http://localhost:${webhooksPort}/`, {
method: 'POST',
headers: {
'X-Hub-Signature': `sha256=${hmac.read().toString('hex')}`,
'x-atlassian-webhook-identifier': randomUUID(),
'Content-Type': 'application/json'
},
body: webhookPayload,
});
expect(req.status).toBe(200);
expect(await req.text()).toBe('OK');

// And await the notice.
const { body } = (await webhookNotice).data.content;
expect(body).toContain('Test User created a new JIRA issue [TP-8](https://example.org/browse/TP-8): "Test issue"');
console.log("Test over");
}, { timeout: 20000 });
});
9 changes: 9 additions & 0 deletions spec/util/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export async function waitFor(condition: () => Promise<boolean>, delay = 100, maxRetries = 10) {
let retries = 0;
while (!await condition() && retries++ < maxRetries) {
await new Promise((r) => setTimeout(r, delay));
}
if (retries === maxRetries) {
throw Error('Hit retry limit');
}
}
9 changes: 7 additions & 2 deletions src/Bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ export class Bridge {
this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready}));
}

public stop() {
public async stop() {
this.feedReader?.stop();
this.houndReader?.stop();
this.tokenStore.stop();
this.as.stop();
if (this.queue.stop) this.queue.stop();
await this.queue.stop?.();
}

public async start() {
Expand Down Expand Up @@ -1152,6 +1152,11 @@ export class Bridge {
} else {
await connection.onStateUpdate?.(event);
}
try {
await this.as.botClient.sendReadReceipt(connection.roomId, event.event_id);
} catch {
// Nonessentail
}
} catch (ex) {
log.warn(`Connection ${connection.toString()} for ${roomId} failed to handle state update:`, ex);
}
Expand Down
1 change: 0 additions & 1 deletion src/Managers/BotUsersManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { promises as fs } from "fs";
import axios from "axios";
import { Appservice, Intent } from "matrix-bot-sdk";
import { Logger } from "matrix-appservice-bridge";

Expand Down
Loading
Loading