mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 21:19:13 +00:00
Merge remote-tracking branch 'origin/main' into hs/provisioning
This commit is contained in:
commit
6ab1f43f43
@ -34,6 +34,10 @@ jira:
|
||||
#
|
||||
webhook:
|
||||
secret: secrettoken
|
||||
oauth:
|
||||
client_id: foo
|
||||
client_secret: bar
|
||||
redirect_uri: https://example.com/bridge_oauth/
|
||||
generic:
|
||||
# (Optional) Support for generic webhook events. `allowJsTransformationFunctions` will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments
|
||||
#
|
||||
|
@ -29,6 +29,7 @@
|
||||
"generate-default-config": "node lib/Config/Defaults.js --config > config.sample.yml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@octokit/auth-app": "^3.3.0",
|
||||
"@octokit/auth-token": "^2.4.5",
|
||||
"@octokit/rest": "^18.10.0",
|
||||
@ -37,6 +38,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"ioredis": "^4.28.0",
|
||||
"jira-client": "^8.0.0",
|
||||
"markdown-it": "^12.2.0",
|
||||
"matrix-bot-sdk": "^0.5.19",
|
||||
"matrix-widget-api": "^0.1.0-beta.17",
|
||||
@ -66,6 +68,7 @@
|
||||
"@types/node": "^12",
|
||||
"@types/node-emoji": "^1.8.1",
|
||||
"@types/uuid": "^8.3.3",
|
||||
"@types/jira-client": "^7.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
||||
"@typescript-eslint/parser": "^5.4.0",
|
||||
"chai": "^4.3.4",
|
||||
|
@ -4,7 +4,6 @@ import { UserTokenStore } from "./UserTokenStore";
|
||||
import { BridgeConfig } from "./Config/Config";
|
||||
import {v4 as uuid} from "uuid";
|
||||
import qs from "querystring";
|
||||
import { EventEmitter } from "events";
|
||||
import LogWrapper from "./LogWrapper";
|
||||
import "reflect-metadata";
|
||||
import markdown from "markdown-it";
|
||||
@ -18,6 +17,8 @@ import { BridgeRoomState, BridgeRoomStateGitHub } from "./Widgets/BridgeWidgetIn
|
||||
import { Endpoints } from "@octokit/types";
|
||||
import { ProjectsListResponseData } from "./Github/Types";
|
||||
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
|
||||
import { JiraBotCommands } from "./Jira/AdminCommands";
|
||||
import { AdminAccountData, AdminRoomCommandHandler } from "./AdminRoomCommandHandler";
|
||||
|
||||
type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"];
|
||||
type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"];
|
||||
@ -33,52 +34,26 @@ export const LEGACY_BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-github.gitla
|
||||
export const BRIDGE_ROOM_TYPE = "uk.half-shot.matrix-hookshot.github.room";
|
||||
export const BRIDGE_NOTIF_TYPE = "uk.half-shot.matrix-hookshot.github.notif_state";
|
||||
export const BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-hookshot.gitlab.notif_state";
|
||||
export interface AdminAccountData {
|
||||
// eslint-disable-next-line camelcase
|
||||
admin_user: string;
|
||||
github?: {
|
||||
notifications?: {
|
||||
enabled: boolean;
|
||||
participating?: boolean;
|
||||
};
|
||||
};
|
||||
gitlab?: {
|
||||
[instanceUrl: string]: {
|
||||
notifications: {
|
||||
enabled: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AdminRoom extends EventEmitter {
|
||||
export class AdminRoom extends AdminRoomCommandHandler {
|
||||
public static helpMessage: () => MatrixMessageContent;
|
||||
private widgetAccessToken = `abcdef`;
|
||||
protected widgetAccessToken = `abcdef`;
|
||||
static botCommands: BotCommands;
|
||||
|
||||
private pendingOAuthState: string|null = null;
|
||||
protected pendingOAuthState: string|null = null;
|
||||
public readonly notifFilter: NotifFilter;
|
||||
|
||||
constructor(public readonly roomId: string,
|
||||
private data: AdminAccountData,
|
||||
constructor(roomId: string,
|
||||
data: AdminAccountData,
|
||||
notifContent: NotificationFilterStateContent,
|
||||
private botIntent: Intent,
|
||||
private tokenStore: UserTokenStore,
|
||||
private config: BridgeConfig) {
|
||||
super();
|
||||
botIntent: Intent,
|
||||
tokenStore: UserTokenStore,
|
||||
config: BridgeConfig) {
|
||||
super(botIntent, roomId, tokenStore, config, data);
|
||||
this.notifFilter = new NotifFilter(notifContent);
|
||||
// TODO: Move this
|
||||
this.backfillAccessToken();
|
||||
}
|
||||
|
||||
public get accountData() {
|
||||
return {...this.data};
|
||||
}
|
||||
|
||||
public get userId() {
|
||||
return this.data.admin_user;
|
||||
}
|
||||
|
||||
public get oauthState() {
|
||||
return this.pendingOAuthState;
|
||||
}
|
||||
@ -156,10 +131,6 @@ export class AdminRoom extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
public async sendNotice(noticeText: string) {
|
||||
return this.botIntent.sendText(this.roomId, noticeText, "m.notice");
|
||||
}
|
||||
|
||||
@botCommand("help", "This help text")
|
||||
public async helpCommand() {
|
||||
return this.botIntent.sendEvent(this.roomId, AdminRoom.helpMessage());
|
||||
@ -654,6 +625,6 @@ export class AdminRoom extends EventEmitter {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const res = compileBotCommands(AdminRoom.prototype as any);
|
||||
const res = compileBotCommands(AdminRoom.prototype as any, JiraBotCommands.prototype as any);
|
||||
AdminRoom.helpMessage = res.helpMessage;
|
||||
AdminRoom.botCommands = res.botCommands;
|
59
src/AdminRoomCommandHandler.ts
Normal file
59
src/AdminRoomCommandHandler.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import EventEmitter from "events";
|
||||
import { Intent } from "matrix-bot-sdk";
|
||||
import { BridgeConfig } from "./Config/Config";
|
||||
import { UserTokenStore } from "./UserTokenStore";
|
||||
|
||||
export interface AdminAccountData {
|
||||
// eslint-disable-next-line camelcase
|
||||
admin_user: string;
|
||||
github?: {
|
||||
notifications?: {
|
||||
enabled: boolean;
|
||||
participating?: boolean;
|
||||
};
|
||||
};
|
||||
gitlab?: {
|
||||
[instanceUrl: string]: {
|
||||
notifications: {
|
||||
enabled: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export abstract class AdminRoomCommandHandler extends EventEmitter {
|
||||
|
||||
// This needs moving to the JIRA specific impl.
|
||||
protected pendingJiraOAuthState: string|null = null;
|
||||
|
||||
public get jiraOAuthState() {
|
||||
return this.pendingJiraOAuthState;
|
||||
}
|
||||
|
||||
public clearJiraOauthState() {
|
||||
this.pendingJiraOAuthState = null;
|
||||
}
|
||||
|
||||
public get accountData() {
|
||||
return {...this.data};
|
||||
}
|
||||
|
||||
public get userId() {
|
||||
return this.data.admin_user;
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected readonly botIntent: Intent,
|
||||
public readonly roomId: string,
|
||||
protected tokenStore: UserTokenStore,
|
||||
protected readonly config: BridgeConfig,
|
||||
protected data: AdminAccountData,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
public async sendNotice(noticeText: string) {
|
||||
return this.botIntent.sendText(this.roomId, noticeText, "m.notice");
|
||||
}
|
||||
|
||||
}
|
@ -5,6 +5,7 @@ import { BridgeConfig, parseRegistrationFile } from "../Config/Config";
|
||||
import { Webhooks } from "../Webhooks";
|
||||
import { MatrixSender } from "../MatrixSender";
|
||||
import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher";
|
||||
import { LogLevel, LogService } from "matrix-bot-sdk";
|
||||
LogWrapper.configureLogging("debug");
|
||||
const log = new LogWrapper("App");
|
||||
|
||||
|
@ -25,9 +25,10 @@ export type BotCommands = {[prefix: string]: {
|
||||
includeUserId: boolean,
|
||||
}};
|
||||
|
||||
export function compileBotCommands(prototype: Record<string, BotCommandFunction>): {helpMessage: (cmdPrefix?: string) => MatrixMessageContent, botCommands: BotCommands} {
|
||||
export function compileBotCommands(...prototypes: Record<string, BotCommandFunction>[]): {helpMessage: (cmdPrefix?: string) => MatrixMessageContent, botCommands: BotCommands} {
|
||||
let content = "Commands:\n";
|
||||
const botCommands: BotCommands = {};
|
||||
prototypes.forEach(prototype => {
|
||||
Object.getOwnPropertyNames(prototype).forEach(propetyKey => {
|
||||
const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey);
|
||||
if (b) {
|
||||
@ -43,6 +44,7 @@ export function compileBotCommands(prototype: Record<string, BotCommandFunction>
|
||||
};
|
||||
}
|
||||
});
|
||||
})
|
||||
return {
|
||||
helpMessage: (cmdPrefix?: string) => ({
|
||||
msgtype: "m.notice",
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { AdminRoom, BRIDGE_ROOM_TYPE, AdminAccountData } from "./AdminRoom";
|
||||
import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom";
|
||||
import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, PantalaimonClient, MatrixClient } from "matrix-bot-sdk";
|
||||
import { BridgeConfig, BridgeConfigProvisioning, GitLabInstance } from "./Config/Config";
|
||||
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
|
||||
@ -13,14 +13,14 @@ import { GitLabIssueConnection } from "./Connections/GitlabIssue";
|
||||
import { IBridgeStorageProvider } from "./Stores/StorageProvider";
|
||||
import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace } from "./Connections";
|
||||
import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes";
|
||||
import { JiraIssueEvent } from "./Jira/WebhookTypes";
|
||||
import { JiraIssueEvent, JiraIssueUpdatedEvent } from "./Jira/WebhookTypes";
|
||||
import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent";
|
||||
import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider";
|
||||
import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue";
|
||||
import { MessageSenderClient } from "./MatrixSender";
|
||||
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
|
||||
import { NotificationProcessor } from "./NotificationsProcessor";
|
||||
import { OAuthRequest, OAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent,} from "./Webhooks";
|
||||
import { OAuthRequest, GitHubOAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent,} from "./Webhooks";
|
||||
import { ProjectsGetResponseData } from "./Github/Types";
|
||||
import { RedisStorageProvider } from "./Stores/RedisStorageProvider";
|
||||
import { retry } from "./PromiseUtil";
|
||||
@ -29,6 +29,8 @@ import { UserTokenStore } from "./UserTokenStore";
|
||||
import * as GitHubWebhookTypes from "@octokit/webhooks-types";
|
||||
import LogWrapper from "./LogWrapper";
|
||||
import { Provisioner } from "./provisioning/provisioner";
|
||||
import { JiraOAuthResult } from "./Jira/Types";
|
||||
import { AdminAccountData } from "./AdminRoomCommandHandler";
|
||||
const log = new LogWrapper("Bridge");
|
||||
|
||||
export class Bridge {
|
||||
@ -68,7 +70,7 @@ export class Bridge {
|
||||
this.messageClient = new MessageSenderClient(this.queue);
|
||||
this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url);
|
||||
this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient);
|
||||
this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent);
|
||||
this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config);
|
||||
}
|
||||
|
||||
public stop() {
|
||||
@ -150,6 +152,7 @@ export class Bridge {
|
||||
this.queue.subscribe("notifications.user.events");
|
||||
this.queue.subscribe("github.*");
|
||||
this.queue.subscribe("gitlab.*");
|
||||
this.queue.subscribe("jira.*");
|
||||
|
||||
const validateRepoIssue = (data: GitHubWebhookTypes.IssuesEvent|GitHubWebhookTypes.IssueCommentEvent) => {
|
||||
if (!data.repository || !data.issue) {
|
||||
@ -339,7 +342,7 @@ export class Bridge {
|
||||
});
|
||||
});
|
||||
|
||||
this.queue.on<OAuthTokens>("oauth.tokens", async (msg) => {
|
||||
this.queue.on<GitHubOAuthTokens>("oauth.tokens", async (msg) => {
|
||||
const adminRoom = [...this.adminRooms.values()].find((r) => r.oauthState === msg.data.state);
|
||||
if (!adminRoom) {
|
||||
log.warn("Could not find admin room for successful tokens request. This shouldn't happen!");
|
||||
@ -436,8 +439,7 @@ export class Bridge {
|
||||
|
||||
this.queue.on<JiraIssueEvent>("jira.issue_created", async ({data}) => {
|
||||
log.info(`JIRA issue created for project ${data.issue.fields.project.id}, issue id ${data.issue.id}`);
|
||||
const projectId = data.issue.fields.project.id;
|
||||
const connections = connManager.getConnectionsForJiraProject(projectId, "jira.issue_created");
|
||||
const connections = connManager.getConnectionsForJiraProject(data.issue.fields.project, "jira.issue_created");
|
||||
|
||||
connections.forEach(async (c) => {
|
||||
try {
|
||||
@ -448,6 +450,41 @@ export class Bridge {
|
||||
});
|
||||
});
|
||||
|
||||
this.queue.on<JiraIssueEvent>("jira.issue_updated", async ({data}) => {
|
||||
log.info(`JIRA issue created for project ${data.issue.fields.project.id}, issue id ${data.issue.id}`);
|
||||
const connections = connManager.getConnectionsForJiraProject(data.issue.fields.project, "jira.issue_updated");
|
||||
|
||||
connections.forEach(async (c) => {
|
||||
try {
|
||||
await c.onJiraIssueUpdated(data as JiraIssueUpdatedEvent);
|
||||
} catch (ex) {
|
||||
log.warn(`Failed to handle jira.issue_updated:`, ex);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
this.queue.on<OAuthRequest>("jira.oauth.response", async (msg) => {
|
||||
const adminRoom = [...this.adminRooms.values()].find((r) => r.jiraOAuthState === msg.data.state);
|
||||
await this.queue.push<boolean>({
|
||||
data: !!(adminRoom),
|
||||
sender: "Bridge",
|
||||
messageId: msg.messageId,
|
||||
eventName: "response.jira.oauth.response",
|
||||
});
|
||||
});
|
||||
|
||||
this.queue.on<JiraOAuthResult>("jira.oauth.tokens", async (msg) => {
|
||||
const adminRoom = [...this.adminRooms.values()].find((r) => r.jiraOAuthState === msg.data.state);
|
||||
if (!adminRoom) {
|
||||
log.warn("Could not find admin room for successful tokens request. This shouldn't happen!");
|
||||
return;
|
||||
}
|
||||
adminRoom.clearJiraOauthState();
|
||||
await this.tokenStore.storeUserToken("jira", adminRoom.userId, JSON.stringify(msg.data));
|
||||
await adminRoom.sendNotice(`Logged into Jira`);
|
||||
});
|
||||
|
||||
this.queue.on<GenericWebhookEvent>("generic-webhook.event", async ({data}) => {
|
||||
log.info(`Incoming generic hook ${data.hookId}`);
|
||||
const connections = connManager.getConnectionsForGenericWebhook(data.hookId);
|
||||
@ -512,12 +549,20 @@ export class Bridge {
|
||||
|
||||
// TODO: Refactor this to be a connection
|
||||
try {
|
||||
const accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
|
||||
let accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
|
||||
BRIDGE_ROOM_TYPE, roomId,
|
||||
);
|
||||
if (!accountData) {
|
||||
accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
|
||||
LEGACY_BRIDGE_ROOM_TYPE, roomId,
|
||||
);
|
||||
if (!accountData) {
|
||||
log.debug(`Room ${roomId} has no connections and is not an admin room`);
|
||||
continue;
|
||||
} else {
|
||||
// Upgrade the room
|
||||
await this.as.botClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData);
|
||||
}
|
||||
}
|
||||
|
||||
let notifContent;
|
||||
|
@ -39,10 +39,18 @@ interface BridgeConfigGitLab {
|
||||
instances: {[name: string]: GitLabInstance};
|
||||
}
|
||||
|
||||
interface BridgeConfigJira {
|
||||
export interface BridgeConfigJira {
|
||||
webhook: {
|
||||
secret: string;
|
||||
};
|
||||
oauth: {
|
||||
// eslint-disable-next-line camelcase
|
||||
client_id: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
client_secret: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
redirect_uri: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface BridgeGenericWebhooksConfig {
|
||||
@ -96,18 +104,18 @@ export interface BridgeConfigProvisioning {
|
||||
}
|
||||
|
||||
interface BridgeConfigRoot {
|
||||
bot?: BridgeConfigBot;
|
||||
bridge: BridgeConfigBridge;
|
||||
webhook: BridgeConfigWebhook;
|
||||
queue: BridgeConfigQueue;
|
||||
logging: BridgeConfigLogging;
|
||||
passFile: string;
|
||||
generic?: BridgeGenericWebhooksConfig;
|
||||
github?: BridgeConfigGitHub;
|
||||
gitlab?: BridgeConfigGitLab;
|
||||
provisioning?: BridgeConfigProvisioning;
|
||||
jira?: BridgeConfigJira;
|
||||
bot?: BridgeConfigBot;
|
||||
logging: BridgeConfigLogging;
|
||||
passFile: string;
|
||||
queue: BridgeConfigQueue;
|
||||
webhook: BridgeConfigWebhook;
|
||||
widgets?: BridgeWidgetConfig;
|
||||
generic?: BridgeGenericWebhooksConfig;
|
||||
}
|
||||
|
||||
export class BridgeConfig {
|
||||
|
@ -4,7 +4,7 @@ import { getConfigKeyMetadata } from "./Decorators";
|
||||
import { Node, YAMLSeq } from "yaml/types";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const DefaultConfig = new BridgeConfig({
|
||||
export const DefaultConfig = new BridgeConfig({
|
||||
bridge: {
|
||||
domain: "example.com",
|
||||
url: "http://localhost:8008",
|
||||
@ -62,7 +62,12 @@ const DefaultConfig = new BridgeConfig({
|
||||
jira: {
|
||||
webhook: {
|
||||
secret: 'secrettoken'
|
||||
}
|
||||
},
|
||||
oauth: {
|
||||
client_id: "foo",
|
||||
client_secret: "bar",
|
||||
redirect_uri: "https://example.com/bridge_oauth/",
|
||||
},
|
||||
},
|
||||
generic: {
|
||||
enabled: false,
|
||||
|
@ -12,6 +12,7 @@ import { GenericHookConnection } from "./Connections/GenericHook";
|
||||
import { JiraProjectConnection } from "./Connections/JiraProject";
|
||||
import { GithubInstance } from "./Github/GithubInstance";
|
||||
import { GitLabClient } from "./Gitlab/Client";
|
||||
import { JiraProject } from "./Jira/Types";
|
||||
import LogWrapper from "./LogWrapper";
|
||||
import { MessageSenderClient } from "./MatrixSender";
|
||||
import { UserTokenStore } from "./UserTokenStore";
|
||||
@ -41,7 +42,8 @@ export class ConnectionManager {
|
||||
public push(...connections: IConnection[]) {
|
||||
// NOTE: Double loop
|
||||
for (const connection of connections) {
|
||||
if (this.connections.find((c) => c !== connection)) {
|
||||
if (!this.connections.find((c) => c === connection)) {
|
||||
console.log("PUSH!");
|
||||
this.connections.push(connection);
|
||||
}
|
||||
}
|
||||
@ -49,8 +51,8 @@ export class ConnectionManager {
|
||||
}
|
||||
|
||||
public async createConnectionForState(roomId: string, state: StateEvent<any>) {
|
||||
log.debug(`Looking to create connection for ${roomId}`);
|
||||
if (state.content.disabled === false) {
|
||||
log.debug(`Looking to create connection for ${roomId} ${state.type}`);
|
||||
if (state.content.disabled === true) {
|
||||
log.debug(`${roomId} has disabled state for ${state.type}`);
|
||||
return;
|
||||
}
|
||||
@ -128,10 +130,11 @@ export class ConnectionManager {
|
||||
}
|
||||
|
||||
if (JiraProjectConnection.EventTypes.includes(state.type)) {
|
||||
console.log("WOOF", state);
|
||||
if (!this.config.jira) {
|
||||
throw Error('JIRA is not configured');
|
||||
}
|
||||
return new JiraProjectConnection(roomId, this.as, state.content, state.stateKey, this.commentProcessor, this.messageClient);
|
||||
return new JiraProjectConnection(roomId, this.as, state.content, state.stateKey, this.commentProcessor, this.messageClient, this.tokenStore);
|
||||
}
|
||||
|
||||
if (GenericHookConnection.EventTypes.includes(state.type) && this.config.generic?.enabled) {
|
||||
@ -227,10 +230,15 @@ export class ConnectionManager {
|
||||
return this.connections.filter((c) => (c instanceof GitLabRepoConnection && c.path === pathWithNamespace)) as GitLabRepoConnection[];
|
||||
}
|
||||
|
||||
public getConnectionsForJiraProject(projectId: string, eventName: string): JiraProjectConnection[] {
|
||||
return this.connections.filter((c) => (c instanceof JiraProjectConnection && c.projectId === projectId && c.isInterestedInHookEvent(eventName))) as JiraProjectConnection[];
|
||||
public getConnectionsForJiraProject(project: JiraProject, eventName: string): JiraProjectConnection[] {
|
||||
console.log(this.connections);
|
||||
return this.connections.filter((c) =>
|
||||
(c instanceof JiraProjectConnection &&
|
||||
c.interestedInProject(project) &&
|
||||
c.isInterestedInHookEvent(eventName))) as JiraProjectConnection[];
|
||||
}
|
||||
|
||||
|
||||
public getConnectionsForGenericWebhook(hookId: string): GenericHookConnection[] {
|
||||
return this.connections.filter((c) => (c instanceof GenericHookConnection && c.hookId === hookId)) as GenericHookConnection[];
|
||||
}
|
||||
|
54
src/Connections/CommandConnection.ts
Normal file
54
src/Connections/CommandConnection.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { botCommand, BotCommands, handleCommand } from "../BotCommands";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { MatrixClient } from "matrix-bot-sdk";
|
||||
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
|
||||
const log = new LogWrapper("CommandConnection");
|
||||
|
||||
export abstract class CommandConnection {
|
||||
constructor(
|
||||
public readonly roomId: string,
|
||||
private readonly botClient: MatrixClient,
|
||||
private readonly botCommands: BotCommands,
|
||||
private readonly helpMessage: (prefix: string) => MatrixMessageContent,
|
||||
protected readonly stateCommandPrefix: string,
|
||||
) { }
|
||||
|
||||
protected get commandPrefix() {
|
||||
return this.stateCommandPrefix + " ";
|
||||
}
|
||||
|
||||
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
|
||||
const { error, handled, humanError } = await handleCommand(ev.sender, ev.content.body, this.botCommands, this, this.commandPrefix);
|
||||
if (!handled) {
|
||||
// Not for us.
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
await this.botClient.sendEvent(this.roomId, "m.reaction", {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: ev.event_id,
|
||||
key: "⛔",
|
||||
}
|
||||
});
|
||||
await this.botClient.sendEvent(this.roomId, 'm.room.message', {
|
||||
msgtype: "m.notice",
|
||||
body: humanError ? `Failed to handle command: ${humanError}` : "Failed to handle command",
|
||||
});
|
||||
log.warn(`Failed to handle command:`, error);
|
||||
return;
|
||||
}
|
||||
await this.botClient.sendEvent(this.roomId, "m.reaction", {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: ev.event_id,
|
||||
key: "✅",
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@botCommand("help", "This help text")
|
||||
public async helpCommand() {
|
||||
return this.botClient.sendEvent(this.roomId, 'm.room.message', this.helpMessage(this.commandPrefix));
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import { Appservice } from "matrix-bot-sdk";
|
||||
import { BotCommands, handleCommand, botCommand, compileBotCommands } from "../BotCommands";
|
||||
import { BotCommands, botCommand, compileBotCommands } from "../BotCommands";
|
||||
import { CommentProcessor } from "../CommentProcessor";
|
||||
import { FormatUtil } from "../FormatUtil";
|
||||
import { IConnection } from "./IConnection";
|
||||
@ -15,6 +15,7 @@ import axios from "axios";
|
||||
import emoji from "node-emoji";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import markdown from "markdown-it";
|
||||
import { CommandConnection } from "./CommandConnection";
|
||||
const log = new LogWrapper("GitHubRepoConnection");
|
||||
const md = new markdown();
|
||||
|
||||
@ -57,7 +58,7 @@ function compareEmojiStrings(e0: string, e1: string, e0Index = 0) {
|
||||
/**
|
||||
* Handles rooms connected to a github repo.
|
||||
*/
|
||||
export class GitHubRepoConnection implements IConnection {
|
||||
export class GitHubRepoConnection extends CommandConnection implements IConnection {
|
||||
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.repository";
|
||||
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.repository";
|
||||
|
||||
@ -145,12 +146,18 @@ export class GitHubRepoConnection implements IConnection {
|
||||
static helpMessage: (cmdPrefix: string) => MatrixMessageContent;
|
||||
static botCommands: BotCommands;
|
||||
|
||||
constructor(public readonly roomId: string,
|
||||
constructor(roomId: string,
|
||||
private readonly as: Appservice,
|
||||
private readonly state: GitHubRepoConnectionState,
|
||||
private readonly tokenStore: UserTokenStore,
|
||||
private readonly stateKey: string) {
|
||||
|
||||
super(
|
||||
roomId,
|
||||
as.botClient,
|
||||
GitHubRepoConnection.botCommands,
|
||||
GitHubRepoConnection.helpMessage,
|
||||
state.commandPrefix || "!gh"
|
||||
);
|
||||
}
|
||||
|
||||
public get org() {
|
||||
@ -161,49 +168,11 @@ export class GitHubRepoConnection implements IConnection {
|
||||
return this.state.repo.toLowerCase();
|
||||
}
|
||||
|
||||
private get commandPrefix() {
|
||||
return (this.state.commandPrefix || "!gh") + " ";
|
||||
}
|
||||
|
||||
public isInterestedInStateEvent(eventType: string, stateKey: string) {
|
||||
return GitHubRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
|
||||
}
|
||||
|
||||
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
|
||||
const { error, handled, humanError } = await handleCommand(ev.sender, ev.content.body, GitHubRepoConnection.botCommands, this, this.commandPrefix);
|
||||
if (!handled) {
|
||||
// Not for us.
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: ev.event_id,
|
||||
key: "⛔",
|
||||
}
|
||||
});
|
||||
await this.as.botIntent.sendEvent(this.roomId,{
|
||||
msgtype: "m.notice",
|
||||
body: humanError ? `Failed to handle command: ${humanError}` : "Failed to handle command",
|
||||
});
|
||||
log.warn(`Failed to handle command:`, error);
|
||||
return;
|
||||
}
|
||||
await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: ev.event_id,
|
||||
key: "✅",
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@botCommand("help", "This help text")
|
||||
public async helpCommand() {
|
||||
return this.as.botIntent.sendEvent(this.roomId, GitHubRepoConnection.helpMessage(this.commandPrefix));
|
||||
}
|
||||
|
||||
@botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true)
|
||||
// @ts-ignore
|
||||
private async onCreateIssue(userId: string, title: string, description?: string, labels?: string) {
|
||||
@ -539,6 +508,6 @@ ${event.release.body}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const res = compileBotCommands(GitHubRepoConnection.prototype as any);
|
||||
const res = compileBotCommands(GitHubRepoConnection.prototype as any, CommandConnection.prototype as any);
|
||||
GitHubRepoConnection.helpMessage = res.helpMessage;
|
||||
GitHubRepoConnection.botCommands = res.botCommands;
|
||||
|
@ -3,16 +3,26 @@ import { Appservice } from "matrix-bot-sdk";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { CommentProcessor } from "../CommentProcessor";
|
||||
import { MessageSenderClient } from "../MatrixSender"
|
||||
import { JiraIssueEvent } from "../Jira/WebhookTypes";
|
||||
import { JiraIssueEvent, JiraIssueUpdatedEvent } from "../Jira/WebhookTypes";
|
||||
import { FormatUtil } from "../FormatUtil";
|
||||
import markdownit from "markdown-it";
|
||||
import { generateJiraWebLinkFromIssue } from "../Jira";
|
||||
import { JiraIssue, JiraProject } from "../Jira/Types";
|
||||
import { botCommand, BotCommands, compileBotCommands } from "../BotCommands";
|
||||
import { MatrixMessageContent } from "../MatrixEvent";
|
||||
import { CommandConnection } from "./CommandConnection";
|
||||
import { start } from "repl";
|
||||
import { UserTokenStore } from "../UserTokenStore";
|
||||
import { CommandError, NotLoggedInError } from "../errors";
|
||||
|
||||
type JiraAllowedEventsNames = "issue.created";
|
||||
const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"];
|
||||
export interface JiraProjectConnectionState {
|
||||
id: string;
|
||||
// legacy field, prefer url
|
||||
id?: string;
|
||||
url?: string;
|
||||
events?: JiraAllowedEventsNames[],
|
||||
commandPrefix?: string;
|
||||
}
|
||||
|
||||
const log = new LogWrapper("JiraProjectConnection");
|
||||
@ -21,7 +31,9 @@ const md = new markdownit();
|
||||
/**
|
||||
* Handles rooms connected to a github repo.
|
||||
*/
|
||||
export class JiraProjectConnection implements IConnection {
|
||||
export class JiraProjectConnection extends CommandConnection implements IConnection {
|
||||
|
||||
|
||||
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project";
|
||||
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project";
|
||||
|
||||
@ -29,6 +41,8 @@ export class JiraProjectConnection implements IConnection {
|
||||
JiraProjectConnection.CanonicalEventType,
|
||||
JiraProjectConnection.LegacyCanonicalEventType,
|
||||
];
|
||||
static botCommands: BotCommands;
|
||||
static helpMessage: (cmdPrefix?: string) => MatrixMessageContent;
|
||||
|
||||
static getTopicString(authorName: string, state: string) {
|
||||
`Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}`
|
||||
@ -38,16 +52,58 @@ export class JiraProjectConnection implements IConnection {
|
||||
return this.state.id;
|
||||
}
|
||||
|
||||
public get instanceOrigin() {
|
||||
return this.projectUrl?.origin;
|
||||
}
|
||||
|
||||
public get projectKey() {
|
||||
const parts = this.projectUrl?.pathname.split('/');
|
||||
return parts ? parts[parts.length - 1] : undefined;
|
||||
}
|
||||
|
||||
public isInterestedInHookEvent(eventName: string) {
|
||||
return !this.state.events || this.state.events?.includes(eventName as JiraAllowedEventsNames);
|
||||
}
|
||||
|
||||
constructor(public readonly roomId: string,
|
||||
public interestedInProject(project: JiraProject) {
|
||||
if (this.projectId === project.id) {
|
||||
return true;
|
||||
}
|
||||
if (this.instanceOrigin) {
|
||||
const url = new URL(project.self);
|
||||
return this.instanceOrigin === url.origin && this.projectKey === project.key;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL of the project
|
||||
* @example https://test.atlassian.net/jira/software/c/projects/PLAY
|
||||
*/
|
||||
private projectUrl?: URL;
|
||||
|
||||
constructor(roomId: string,
|
||||
private readonly as: Appservice,
|
||||
private state: JiraProjectConnectionState,
|
||||
private readonly stateKey: string,
|
||||
private commentProcessor: CommentProcessor,
|
||||
private messageClient: MessageSenderClient,) {
|
||||
private readonly commentProcessor: CommentProcessor,
|
||||
private readonly messageClient: MessageSenderClient,
|
||||
private readonly tokenStore: UserTokenStore,) {
|
||||
super(
|
||||
roomId,
|
||||
as.botClient,
|
||||
JiraProjectConnection.botCommands,
|
||||
JiraProjectConnection.helpMessage,
|
||||
state.commandPrefix || "!jira"
|
||||
);
|
||||
if (state.url) {
|
||||
this.projectUrl = new URL(state.url);
|
||||
} else if (state.id) {
|
||||
log.warn(`Legacy ID option in use, needs to be switched to 'url'`);
|
||||
} else {
|
||||
throw Error('State is missing both id and url, cannot create connection');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public isInterestedInStateEvent(eventType: string, stateKey: string) {
|
||||
@ -83,7 +139,155 @@ export class JiraProjectConnection implements IConnection {
|
||||
}
|
||||
}
|
||||
|
||||
public async onJiraIssueUpdated(data: JiraIssueUpdatedEvent) {
|
||||
log.info(`onJiraIssueUpdated ${this.roomId} ${this.projectId} ${data.issue.id}`);
|
||||
const url = generateJiraWebLinkFromIssue(data.issue);
|
||||
let content = `${data.user.displayName} updated JIRA [${data.issue.key}](${url}): `;
|
||||
|
||||
const changes = data.changelog.items.map((change) => `**${change.field}** changed from '${change.fromString || "not set"}' to '${change.toString || "not set"}'`);
|
||||
|
||||
if (changes.length < 0) {
|
||||
// Empty changeset?
|
||||
log.warn(`Empty changeset, not sending message`);
|
||||
return;
|
||||
} else if (changes.length === 1) {
|
||||
content += changes[0];
|
||||
} else {
|
||||
content += `\n - ` + changes.join(`\n - `);
|
||||
}
|
||||
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.renderInline(content),
|
||||
format: "org.matrix.custom.html",
|
||||
...FormatUtil.getPartialBodyForJiraIssue(data.issue)
|
||||
});
|
||||
}
|
||||
|
||||
private async getUserClientForProject(userId: string) {
|
||||
const jiraClient = await this.tokenStore.getJiraForUser(userId);
|
||||
if (!jiraClient) {
|
||||
throw new NotLoggedInError();
|
||||
}
|
||||
const resource = (await jiraClient.getAccessibleResources()).find((r) => new URL(r.url).origin === this.instanceOrigin);
|
||||
if (!resource) {
|
||||
throw new CommandError("No-resource", "You do not have permission to create issues for this JIRA org");
|
||||
}
|
||||
return jiraClient.getClientForResource(resource);
|
||||
}
|
||||
|
||||
@botCommand("create", "Create an issue for this project", ["type", "title"], ["description", "labels"], true)
|
||||
public async onCreateIssue(userId: string, type: string, title: string, description?: string, labels?: string) {
|
||||
const api = await this.getUserClientForProject(userId);
|
||||
const keyOrId = this.projectKey || this.projectId;
|
||||
if (!keyOrId) {
|
||||
throw Error('Neither Key or ID are specified');
|
||||
}
|
||||
const project = await api.getProject(keyOrId);
|
||||
if (!project.issueTypes || project.issueTypes.length === 0) {
|
||||
throw new CommandError("project has no issue types", "Cannot create issue, project has no issue types");
|
||||
}
|
||||
const issueTypeId = project.issueTypes.find((issueType) => issueType.name.toLowerCase() === type.toLowerCase())?.id;
|
||||
if (!issueTypeId) {
|
||||
const content = project.issueTypes.map((t) => t.name).join(', ');
|
||||
throw new CommandError("invalid-issuetype", `You must specify a valid issue type (one of ${content}). E.g. ${this.commandPrefix} create ${project.issueTypes[0].name}`);
|
||||
}
|
||||
log.info(`Creating new issue on behalf of ${userId}`);
|
||||
let result: any;
|
||||
try {
|
||||
result = await api.addNewIssue({
|
||||
//update: {},
|
||||
fields: {
|
||||
"summary": title,
|
||||
"project": {
|
||||
"key": this.projectKey,
|
||||
},
|
||||
"issuetype": {
|
||||
id: issueTypeId,
|
||||
},
|
||||
...( description ? {"description": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"text": description,
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}} : undefined),
|
||||
...( labels ? {"labels": labels.split(",")} : undefined),
|
||||
}
|
||||
})
|
||||
} catch (ex) {
|
||||
log.warn("Failed to create JIRA issue:", ex);
|
||||
throw new CommandError(ex.message, "Failed to create JIRA issue");
|
||||
}
|
||||
|
||||
const link = generateJiraWebLinkFromIssue({self: this.projectUrl?.toString() || result.self, key: result.key as string});
|
||||
const content = `Created JIRA issue ${result.key}: [${link}](${link})`;
|
||||
return this.as.botIntent.sendEvent(this.roomId,{
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
format: "org.matrix.custom.html"
|
||||
});
|
||||
}
|
||||
|
||||
@botCommand("issue-types", "Get issue types for this project", [], [], true)
|
||||
public async getIssueTypes(userId: string) {
|
||||
const api = await this.getUserClientForProject(userId);
|
||||
log.info(`Creating new issue on behalf of ${userId}`);
|
||||
let result: JiraProject;
|
||||
try {
|
||||
const keyOrId = this.projectKey || this.projectId;
|
||||
if (!keyOrId) {
|
||||
throw Error('Neither Key or ID are specified');
|
||||
}
|
||||
result = await api.getProject(keyOrId);
|
||||
} catch (ex) {
|
||||
log.warn("Failed to get issue types:", ex);
|
||||
throw new CommandError(ex.message, "Failed to create JIRA issue");
|
||||
}
|
||||
|
||||
const content = `Issue types: ${(result.issueTypes || []).map((t) => t.name).join(', ')}`;
|
||||
return this.as.botIntent.sendEvent(this.roomId,{
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
format: "org.matrix.custom.html"
|
||||
});
|
||||
}
|
||||
|
||||
@botCommand("assign", "Assign an issue to a user", ["issueKey", "user"], [], true)
|
||||
public async onAssignIssue(userId: string, issueKey: string, user: string) {
|
||||
const api = await this.getUserClientForProject(userId);
|
||||
try {
|
||||
await api.getIssue(issueKey);
|
||||
} catch (ex) {
|
||||
log.warn(`Failed to find issue`, ex);
|
||||
throw new CommandError(ex.message, "Failed to find issue");
|
||||
}
|
||||
|
||||
log.info(`Assinging issue on behalf of ${userId}`);
|
||||
const searchForUser = await api.searchUsers({query: user, maxResults: 1, includeInactive: false, includeActive: true, username: ""});
|
||||
if (searchForUser.length === 0) {
|
||||
throw new CommandError("not-found", `Could not find a user matching '${user}'`);
|
||||
}
|
||||
await api.updateAssigneeWithId(issueKey, searchForUser[0].accountId);
|
||||
}
|
||||
|
||||
public toString() {
|
||||
return `JiraProjectConnection ${this.projectId}`;
|
||||
return `JiraProjectConnection ${this.projectId || this.projectUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const res = compileBotCommands(JiraProjectConnection.prototype as any, CommandConnection.prototype as any);
|
||||
JiraProjectConnection.helpMessage = res.helpMessage;
|
||||
JiraProjectConnection.botCommands = res.botCommands;
|
72
src/Jira/AdminCommands.ts
Normal file
72
src/Jira/AdminCommands.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { AdminRoomCommandHandler } from "../AdminRoomCommandHandler";
|
||||
import { botCommand } from "../BotCommands";
|
||||
import qs from "querystring";
|
||||
import {v4 as uuid} from "uuid";
|
||||
import { JiraAPIAccessibleResource } from "./Types";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
|
||||
const log = new LogWrapper('JiraBotCommands');
|
||||
|
||||
const JiraOAuthScopes = [
|
||||
// Reading issues, comments
|
||||
"read:jira-work",
|
||||
// Creating issues, comments
|
||||
"write:jira-work",
|
||||
// Reading user
|
||||
"read:jira-user",
|
||||
"read:me",
|
||||
"read:account",
|
||||
// To get a refresh token
|
||||
"offline_access",
|
||||
];
|
||||
|
||||
export class JiraBotCommands extends AdminRoomCommandHandler {
|
||||
@botCommand("jira login", "Login to JIRA")
|
||||
public async loginCommand() {
|
||||
if (!this.config.jira?.oauth) {
|
||||
this.sendNotice(`Bot is not configured with JIRA OAuth support`);
|
||||
return;
|
||||
}
|
||||
this.pendingJiraOAuthState = uuid();
|
||||
const options = {
|
||||
audience: "api.atlassian.com",
|
||||
client_id: this.config.jira.oauth.client_id,
|
||||
scope: JiraOAuthScopes.join(" "),
|
||||
redirect_uri: this.config.jira.oauth.redirect_uri,
|
||||
state: this.pendingJiraOAuthState,
|
||||
response_type: "code",
|
||||
prompt: "consent",
|
||||
};
|
||||
const url = `https://auth.atlassian.com/authorize?${qs.stringify(options)}`;
|
||||
await this.sendNotice(`To login, open ${url} and follow the steps`);
|
||||
}
|
||||
|
||||
@botCommand("jira whoami", "Determine JIRA identity")
|
||||
public async whoami() {
|
||||
if (!this.config.jira) {
|
||||
await this.sendNotice(`Bot is not configured with JIRA OAuth support`);
|
||||
return;
|
||||
}
|
||||
const client = await this.tokenStore.getJiraForUser(this.userId);
|
||||
|
||||
if (!client) {
|
||||
await this.sendNotice(`You are not logged into JIRA`);
|
||||
return;
|
||||
}
|
||||
// Get all resources for user
|
||||
let resources: JiraAPIAccessibleResource[];
|
||||
try {
|
||||
resources = await client.getAccessibleResources();
|
||||
} catch (ex) {
|
||||
log.warn(`Could not request resources from JIRA API: `, ex);
|
||||
await this.sendNotice(`Could not request JIRA resources due to an error`);
|
||||
return;
|
||||
}
|
||||
let response = resources.length === 0 ? `You do not have any instances authorised with this bot` : 'You have access to the following instances:';
|
||||
for (const resource of resources) {
|
||||
const user = await (await client.getClientForResource(resource)).getCurrentUser();
|
||||
response += `\n - ${resource.name} ${user.name} (${user.displayName || ""})`;
|
||||
}
|
||||
await this.sendNotice(response);
|
||||
}
|
||||
}
|
113
src/Jira/Client.ts
Normal file
113
src/Jira/Client.ts
Normal file
@ -0,0 +1,113 @@
|
||||
|
||||
import axios from 'axios';
|
||||
import JiraApi, { SearchUserOptions } from 'jira-client';
|
||||
import QuickLRU from "@alloc/quick-lru";
|
||||
import { JiraAccount, JiraAPIAccessibleResource, JiraIssue, JiraOAuthResult, JiraProject } from './Types';
|
||||
import { BridgeConfigJira } from '../Config/Config';
|
||||
|
||||
const ACCESSIBLE_RESOURCE_CACHE_LIMIT = 100;
|
||||
const ACCESSIBLE_RESOURCE_CACHE_TTL_MS = 60000;
|
||||
|
||||
export class HookshotJiraApi extends JiraApi {
|
||||
constructor(private options: JiraApi.JiraApiOptions) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
async getProject(projectIdOrKey: string) {
|
||||
return await super.getProject(projectIdOrKey) as JiraProject;
|
||||
}
|
||||
|
||||
async getIssue(issueIdOrKey: string) {
|
||||
const res = await axios.get<JiraIssue>(`https://api.atlassian.com/${this.options.base}/rest/api/3/issue/${issueIdOrKey}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.bearer}`
|
||||
},
|
||||
responseType: 'json',
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async searchUsers(opts: SearchUserOptions): Promise<JiraAccount[]> {
|
||||
return super.searchUsers(opts) as unknown as JiraAccount[];
|
||||
}
|
||||
}
|
||||
|
||||
export class JiraClient {
|
||||
|
||||
/**
|
||||
* Cache of accessible resources for a user.
|
||||
*/
|
||||
static resourceCache = new QuickLRU<string, Promise<JiraAPIAccessibleResource[]>>({
|
||||
maxSize: ACCESSIBLE_RESOURCE_CACHE_LIMIT,
|
||||
maxAge: ACCESSIBLE_RESOURCE_CACHE_TTL_MS
|
||||
});
|
||||
|
||||
constructor(private oauth2State: JiraOAuthResult, private readonly onTokenRefreshed: (newData: JiraOAuthResult) => Promise<void>, private readonly config: BridgeConfigJira) {
|
||||
|
||||
}
|
||||
|
||||
private get bearer() {
|
||||
return this.oauth2State.access_token;
|
||||
}
|
||||
|
||||
async getAccessibleResources() {
|
||||
try {
|
||||
const existingPromise = JiraClient.resourceCache.get(this.bearer);
|
||||
if (existingPromise) {
|
||||
return await existingPromise;
|
||||
}
|
||||
} catch (ex) {
|
||||
// Existing failed promise, break out and try again.
|
||||
JiraClient.resourceCache.delete(this.bearer);
|
||||
}
|
||||
const promise = (async () => {
|
||||
const res = await axios.get(`https://api.atlassian.com/oauth/token/accessible-resources`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bearer}`
|
||||
},
|
||||
responseType: 'json',
|
||||
});
|
||||
return res.data as JiraAPIAccessibleResource[];
|
||||
})();
|
||||
JiraClient.resourceCache.set(this.bearer, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async checkTokenAge() {
|
||||
if (this.oauth2State.expires_in + 60000 > Date.now()) {
|
||||
return;
|
||||
}
|
||||
// Refresh the token
|
||||
const res = await axios.post<unknown, JiraOAuthResult>(`https://api.atlassian.com/oauth/token`, {
|
||||
grant_type: "refresh_token",
|
||||
client_id: this.config.oauth.client_id,
|
||||
client_secret: this.config.oauth.client_secret,
|
||||
refresh_token: this.oauth2State.refresh_token,
|
||||
});
|
||||
res.expires_in += Date.now() + (res.expires_in * 1000);
|
||||
this.oauth2State = res;
|
||||
}
|
||||
|
||||
async getClientForName(name: string) {
|
||||
const resources = await this.getAccessibleResources();
|
||||
const resource = resources.find((res) => res.name === name);
|
||||
await this.checkTokenAge();
|
||||
if (!resource) {
|
||||
throw Error('User does not have access to this resource');
|
||||
}
|
||||
return this.getClientForResource(resource);
|
||||
}
|
||||
|
||||
async getClientForResource(res: JiraAPIAccessibleResource) {
|
||||
// Check token age
|
||||
await this.checkTokenAge();
|
||||
return new HookshotJiraApi({
|
||||
protocol: 'https',
|
||||
host: `api.atlassian.com`,
|
||||
base: `/ex/jira/${res.id}`,
|
||||
apiVersion: '3',
|
||||
strictSSL: true,
|
||||
bearer: this.bearer,
|
||||
});
|
||||
}
|
||||
}
|
62
src/Jira/Router.ts
Normal file
62
src/Jira/Router.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import axios from "axios";
|
||||
import { Router, Request, Response } from "express";
|
||||
import { BridgeConfigJira } from "../Config/Config";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { MessageQueue } from "../MessageQueue/MessageQueue";
|
||||
import { OAuthRequest } from "../Webhooks";
|
||||
import { JiraOAuthResult } from "./Types";
|
||||
|
||||
const log = new LogWrapper("JiraRouter");
|
||||
|
||||
export default class JiraRouter {
|
||||
constructor(private readonly config: BridgeConfigJira, private readonly queue: MessageQueue) { }
|
||||
|
||||
private async onOAuth(req: Request, res: Response) {
|
||||
if (typeof req.query.state !== "string") {
|
||||
return res.status(400).send({error: "Missing 'state' parameter"});
|
||||
}
|
||||
if (typeof req.query.code !== "string") {
|
||||
return res.status(400).send({error: "Missing 'state' parameter"});
|
||||
}
|
||||
const state = req.query.state as string;
|
||||
const code = req.query.code as string;
|
||||
log.info(`Got new JIRA oauth request (${state.substring(0, 8)})`);
|
||||
try {
|
||||
const exists = await this.queue.pushWait<OAuthRequest, boolean>({
|
||||
eventName: "jira.oauth.response",
|
||||
sender: "GithubWebhooks",
|
||||
data: {
|
||||
state,
|
||||
},
|
||||
});
|
||||
if (!exists) {
|
||||
return res.status(404).send(`<p>Could not find user which authorised this request. Has it timed out?</p>`);
|
||||
}
|
||||
const accessTokenRes = await axios.post("https://auth.atlassian.com/oauth/token", {
|
||||
client_id: this.config.oauth.client_id,
|
||||
client_secret: this.config.oauth.client_secret,
|
||||
code: code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: this.config.oauth.redirect_uri,
|
||||
});
|
||||
const result = accessTokenRes.data as { access_token: string, scope: string, expires_in: number, refresh_token: string};
|
||||
result.expires_in = Date.now() + (result.expires_in * 1000);
|
||||
log.debug("JIRA token response:", result);
|
||||
await this.queue.push<JiraOAuthResult>({
|
||||
eventName: "jira.oauth.tokens",
|
||||
sender: "GithubWebhooks",
|
||||
data: { state, ... result },
|
||||
});
|
||||
return res.send(`<p> Your account has been bridged </p>`);
|
||||
} catch (ex) {
|
||||
log.error("Failed to handle oauth request:", ex);
|
||||
return res.status(500).send(`<p>Encountered an error handing oauth request</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
public getRouter() {
|
||||
const router = Router();
|
||||
router.get("/oauth", this.onOAuth.bind(this));
|
||||
return router;
|
||||
}
|
||||
}
|
@ -1,3 +1,14 @@
|
||||
|
||||
export interface JiraIssueType {
|
||||
self: string;
|
||||
id: string;
|
||||
description: string;
|
||||
iconUrl: string;
|
||||
name: string;
|
||||
subtask: boolean;
|
||||
avatarId: number;
|
||||
hierachyLevel: number;
|
||||
}
|
||||
export interface JiraProject {
|
||||
/**
|
||||
* URL
|
||||
@ -9,6 +20,7 @@ export interface JiraProject {
|
||||
projectTypeKey: string;
|
||||
simplified: boolean;
|
||||
avatarUrls: Record<string, string>;
|
||||
issueTypes?: JiraIssueType[];
|
||||
}
|
||||
|
||||
export interface JiraAccount {
|
||||
@ -55,3 +67,19 @@ export interface JiraIssue {
|
||||
creator?: JiraAccount;
|
||||
}
|
||||
}
|
||||
|
||||
export interface JiraOAuthResult {
|
||||
state: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface JiraAPIAccessibleResource {
|
||||
id: string;
|
||||
url: string,
|
||||
name: string,
|
||||
scopes: string[],
|
||||
avatarUrl: string,
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { JiraComment, JiraIssue } from "./Types";
|
||||
import { JiraAccount, JiraComment, JiraIssue } from "./Types";
|
||||
|
||||
export interface IJiraWebhookEvent {
|
||||
timestamp: number;
|
||||
@ -16,3 +16,20 @@ export interface JiraIssueEvent extends IJiraWebhookEvent {
|
||||
comment: JiraComment;
|
||||
issue: JiraIssue;
|
||||
}
|
||||
|
||||
export interface JiraIssueUpdatedEvent extends JiraIssueEvent {
|
||||
webhookEvent: "issue_updated";
|
||||
user: JiraAccount;
|
||||
changelog: {
|
||||
id: string;
|
||||
items: {
|
||||
field: string;
|
||||
fieldtype: string;
|
||||
fieldId: string;
|
||||
from: string|null;
|
||||
fromString: string|null;
|
||||
to: string|null;
|
||||
toString: null;
|
||||
}[];
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { LogService } from "matrix-bot-sdk";
|
||||
import { LogLevel, LogService } from "matrix-bot-sdk";
|
||||
import util from "util";
|
||||
import winston from "winston";
|
||||
|
||||
@ -67,6 +67,7 @@ export default class LogWrapper {
|
||||
log.verbose(getMessageString(messageOrObject), { module });
|
||||
},
|
||||
});
|
||||
LogService.setLevel(LogLevel.fromString(level));
|
||||
LogService.info("LogWrapper", "Reconfigured logging");
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,17 @@
|
||||
import { GithubInstance } from "./Github/GithubInstance";
|
||||
import { GitLabClient } from "./Gitlab/Client";
|
||||
import { Intent } from "matrix-bot-sdk";
|
||||
import { promises as fs } from "fs";
|
||||
import { publicEncrypt, privateDecrypt } from "crypto";
|
||||
import JiraApi from 'jira-client';
|
||||
import LogWrapper from "./LogWrapper";
|
||||
import { GitLabClient } from "./Gitlab/Client";
|
||||
import { GithubInstance } from "./Github/GithubInstance";
|
||||
import { JiraClient } from "./Jira/Client";
|
||||
import { JiraOAuthResult } from "./Jira/Types";
|
||||
import { BridgeConfig } from "./Config/Config";
|
||||
|
||||
const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:";
|
||||
const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:";
|
||||
const ACCOUNT_DATA_JIRA_TYPE = "uk.half-shot.matrix-hookshot.jira.password-store:";
|
||||
|
||||
const LEGACY_ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:";
|
||||
const LEGACY_ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:";
|
||||
@ -19,16 +24,20 @@ function tokenKey(type: TokenType, userId: string, legacy = false, instanceUrl?:
|
||||
if (type === "github") {
|
||||
return `${legacy ? LEGACY_ACCOUNT_DATA_TYPE : ACCOUNT_DATA_TYPE}${userId}`;
|
||||
}
|
||||
if (type === "jira") {
|
||||
return `${ACCOUNT_DATA_JIRA_TYPE}${userId}`;
|
||||
}
|
||||
if (!instanceUrl) {
|
||||
throw Error(`Expected instanceUrl for ${type}`);
|
||||
}
|
||||
return `${legacy ? LEGACY_ACCOUNT_DATA_GITLAB_TYPE : ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`;
|
||||
}
|
||||
|
||||
const MAX_TOKEN_PART_SIZE = 128;
|
||||
export class UserTokenStore {
|
||||
private key!: Buffer;
|
||||
private userTokens: Map<string, string>;
|
||||
constructor(private keyPath: string, private intent: Intent) {
|
||||
constructor(private keyPath: string, private intent: Intent, private config: BridgeConfig) {
|
||||
this.userTokens = new Map();
|
||||
}
|
||||
|
||||
@ -39,8 +48,14 @@ export class UserTokenStore {
|
||||
|
||||
public async storeUserToken(type: TokenType, userId: string, token: string, instanceUrl?: string): Promise<void> {
|
||||
const key = tokenKey(type, userId, false, instanceUrl);
|
||||
const tokenParts: string[] = [];
|
||||
while (token && token.length > 0) {
|
||||
const part = token.slice(0, MAX_TOKEN_PART_SIZE);
|
||||
token = token.substring(MAX_TOKEN_PART_SIZE);
|
||||
tokenParts.push(publicEncrypt(this.key, Buffer.from(part)).toString("base64"));
|
||||
}
|
||||
const data = {
|
||||
encrypted: publicEncrypt(this.key, Buffer.from(token)).toString("base64"),
|
||||
encrypted: tokenParts,
|
||||
instance: instanceUrl,
|
||||
};
|
||||
await this.intent.underlyingClient.setAccountData(key, data);
|
||||
@ -58,16 +73,15 @@ export class UserTokenStore {
|
||||
try {
|
||||
let obj;
|
||||
if (AllowedTokenTypes.includes(type)) {
|
||||
obj = await this.intent.underlyingClient.getSafeAccountData<{encrypted: string}>(key);
|
||||
obj = await this.intent.underlyingClient.getSafeAccountData<{encrypted: string|string[]}>(key);
|
||||
if (!obj) {
|
||||
obj = await this.intent.underlyingClient.getAccountData<{encrypted: string}>(tokenKey(type, userId, true, instanceUrl));
|
||||
obj = await this.intent.underlyingClient.getAccountData<{encrypted: string|string[]}>(tokenKey(type, userId, true, instanceUrl));
|
||||
}
|
||||
} else {
|
||||
throw Error('Unknown type');
|
||||
}
|
||||
const encryptedTextB64 = obj.encrypted;
|
||||
const encryptedText = Buffer.from(encryptedTextB64, "base64");
|
||||
const token = privateDecrypt(this.key, encryptedText).toString("utf-8");
|
||||
const encryptedParts = typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted;
|
||||
const token = encryptedParts.map((t) => privateDecrypt(this.key, Buffer.from(t, "base64")).toString("utf-8")).join("");
|
||||
this.userTokens.set(key, token);
|
||||
return token;
|
||||
} catch (ex) {
|
||||
@ -94,4 +108,18 @@ export class UserTokenStore {
|
||||
}
|
||||
return new GitLabClient(instanceUrl, senderToken);
|
||||
}
|
||||
|
||||
public async getJiraForUser(userId: string) {
|
||||
if (!this.config.jira?.oauth) {
|
||||
throw Error('Jira not configured');
|
||||
}
|
||||
const jsonData = await this.getUserToken("jira", userId);
|
||||
if (!jsonData) {
|
||||
return null;
|
||||
}
|
||||
// TODO: Hacks
|
||||
return new JiraClient(JSON.parse(jsonData) as JiraOAuthResult, (data) => {
|
||||
return this.storeUserToken('jira', userId, JSON.stringify(data));
|
||||
}, this.config.jira);
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,8 @@ import { Server } from "http";
|
||||
import axios from "axios";
|
||||
import { IGitLabWebhookEvent } from "./Gitlab/WebhookTypes";
|
||||
import { EmitterWebhookEvent, Webhooks as OctokitWebhooks } from "@octokit/webhooks"
|
||||
import { IJiraWebhookEvent, JiraIssueEvent } from "./Jira/WebhookTypes";
|
||||
import { IJiraWebhookEvent } from "./Jira/WebhookTypes";
|
||||
import JiraRouter from "./Jira/Router";
|
||||
const log = new LogWrapper("GithubWebhooks");
|
||||
|
||||
export interface GenericWebhookEvent {
|
||||
@ -17,11 +18,10 @@ export interface GenericWebhookEvent {
|
||||
}
|
||||
|
||||
export interface OAuthRequest {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface OAuthTokens {
|
||||
export interface GitHubOAuthTokens {
|
||||
// eslint-disable-next-line camelcase
|
||||
access_token: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
@ -70,7 +70,11 @@ export class Webhooks extends EventEmitter {
|
||||
verify: this.verifyRequest.bind(this),
|
||||
}));
|
||||
this.expressApp.post("/", this.onPayload.bind(this));
|
||||
this.expressApp.get("/oauth", this.onGetOauth.bind(this));
|
||||
this.expressApp.get("/oauth", this.onGitHubGetOauth.bind(this));
|
||||
this.queue = createMessageQueue(config);
|
||||
if (this.config.jira) {
|
||||
this.expressApp.use("/jira", new JiraRouter(this.config.jira, this.queue).getRouter());
|
||||
}
|
||||
this.queue = createMessageQueue(config);
|
||||
}
|
||||
|
||||
@ -201,7 +205,7 @@ export class Webhooks extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
public async onGetOauth(req: Request, res: Response) {
|
||||
public async onGitHubGetOauth(req: Request, res: Response) {
|
||||
log.info("Got new oauth request");
|
||||
try {
|
||||
if (!this.config.github) {
|
||||
@ -211,7 +215,6 @@ export class Webhooks extends EventEmitter {
|
||||
eventName: "oauth.response",
|
||||
sender: "GithubWebhooks",
|
||||
data: {
|
||||
code: req.query.code as string,
|
||||
state: req.query.state as string,
|
||||
},
|
||||
});
|
||||
@ -228,7 +231,7 @@ export class Webhooks extends EventEmitter {
|
||||
})}`);
|
||||
// eslint-disable-next-line camelcase
|
||||
const result = qs.parse(accessTokenRes.data) as { access_token: string, token_type: string };
|
||||
await this.queue.push<OAuthTokens>({
|
||||
await this.queue.push<GitHubOAuthTokens>({
|
||||
eventName: "oauth.tokens",
|
||||
sender: "GithubWebhooks",
|
||||
data: { state: req.query.state as string, ... result },
|
||||
|
@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { expect } from "chai";
|
||||
import { AdminRoom } from "../src/AdminRoom";
|
||||
import { DefaultConfig } from "../src/Config/Defaults";
|
||||
import { NotifFilter } from "../src/NotificationFilters";
|
||||
import { UserTokenStore } from "../src/UserTokenStore";
|
||||
import { IntentMock } from "./utils/IntentMock";
|
||||
@ -12,7 +13,7 @@ function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, In
|
||||
if (!data.admin_user) {
|
||||
data.admin_user = "@admin:bar";
|
||||
}
|
||||
const tokenStore = new UserTokenStore("notapath", intent);
|
||||
const tokenStore = new UserTokenStore("notapath", intent, DefaultConfig);
|
||||
return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, tokenStore, {} as any, ), intent];
|
||||
}
|
||||
|
||||
|
155
yarn.lock
155
yarn.lock
@ -2,6 +2,11 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@alloc/quick-lru@^5.2.0":
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
|
||||
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
|
||||
|
||||
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.16.0":
|
||||
version "7.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.0.tgz#0dfc80309beec8411e65e706461c408b0bb9b431"
|
||||
@ -181,6 +186,13 @@
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.12.13"
|
||||
|
||||
"@babel/runtime@^7.6.0":
|
||||
version "7.16.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
|
||||
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/template@^7.16.0":
|
||||
version "7.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.0.tgz#d16a35ebf4cd74e202083356fab21dd89363ddd6"
|
||||
@ -623,6 +635,22 @@
|
||||
"@octokit/webhooks-types" "4.15.0"
|
||||
aggregate-error "^3.1.0"
|
||||
|
||||
"@postman/form-data@~3.1.1":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@postman/form-data/-/form-data-3.1.1.tgz#d0446d0d3639a291f5e800e89fa1d0d3723f9414"
|
||||
integrity sha512-vjh8Q2a8S6UCm/KKs31XFJqEEgmbjBmpPNVV2eVav6905wyFAwaUOBGA1NPBI4ERH9MMZc6w0umFgM6WbEPMdg==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.8"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
"@postman/tunnel-agent@^0.6.3":
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/@postman/tunnel-agent/-/tunnel-agent-0.6.3.tgz#23048d8d8618d453c571f03189e944afdc2292b7"
|
||||
integrity sha512-k57fzmAZ2PJGxfOA4SGR05ejorHbVAa/84Hxh/2nAztjNXc4ZjOm9NUIk6/Z6LCrBvJZqjRZbN8e/nROVUPVdg==
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
"@prefresh/babel-plugin@0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@prefresh/babel-plugin/-/babel-plugin-0.4.1.tgz#c4e843f7c5e56c15f1185979a8559c893ffb4a35"
|
||||
@ -780,6 +808,11 @@
|
||||
"@types/node" "*"
|
||||
"@types/responselike" "*"
|
||||
|
||||
"@types/caseless@*":
|
||||
version "0.12.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8"
|
||||
integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==
|
||||
|
||||
"@types/chai@^4.2.22":
|
||||
version "4.2.22"
|
||||
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.22.tgz#47020d7e4cf19194d43b5202f35f75bd2ad35ce7"
|
||||
@ -843,6 +876,14 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/jira-client@^7.1.0":
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/jira-client/-/jira-client-7.1.0.tgz#f3352630eae8e19d9f90ff618b080ad9b5d82420"
|
||||
integrity sha512-KU4YGAD6ls57YL9SRtOmsBSsT20rBJg/PRvVqupGGeaMiGKI+ovWU1B0/Y534wgIyfVT26LEDpVrPRU1eLJXNw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
"@types/request" "*"
|
||||
|
||||
"@types/json-schema@^7.0.9":
|
||||
version "7.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
|
||||
@ -937,6 +978,16 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
|
||||
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
|
||||
|
||||
"@types/request@*":
|
||||
version "2.48.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.7.tgz#a962d11a26e0d71d9a9913d96bb806dc4d4c2f19"
|
||||
integrity sha512-GWP9AZW7foLd4YQxyFZDBepl0lPsWLMEXDZUjQ/c1gqVPDPECrRZyEzuhJdnPWioFCq3Tv0qoGpMD6U+ygd4ZA==
|
||||
dependencies:
|
||||
"@types/caseless" "*"
|
||||
"@types/node" "*"
|
||||
"@types/tough-cookie" "*"
|
||||
form-data "^2.5.0"
|
||||
|
||||
"@types/resolve@1.17.1":
|
||||
version "1.17.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
|
||||
@ -959,6 +1010,11 @@
|
||||
"@types/mime" "^1"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/tough-cookie@*":
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.1.tgz#8f80dd965ad81f3e1bc26d6f5c727e132721ff40"
|
||||
integrity sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg==
|
||||
|
||||
"@types/uuid@^8.3.3":
|
||||
version "8.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.3.tgz#c6a60686d953dbd1b1d45e66f4ecdbd5d471b4d0"
|
||||
@ -1250,7 +1306,7 @@ balanced-match@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
|
||||
base64-js@^1.3.1:
|
||||
base64-js@^1.1.2, base64-js@^1.3.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
@ -1310,6 +1366,11 @@ bl@^4.1.0:
|
||||
inherits "^2.0.4"
|
||||
readable-stream "^3.4.0"
|
||||
|
||||
bluebird@^2.6.2:
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1"
|
||||
integrity sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=
|
||||
|
||||
bluebird@^3.5.0:
|
||||
version "3.7.2"
|
||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
|
||||
@ -1358,6 +1419,13 @@ braces@^3.0.1, braces@~3.0.2:
|
||||
dependencies:
|
||||
fill-range "^7.0.1"
|
||||
|
||||
brotli@~1.3.2:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.2.tgz#525a9cad4fcba96475d7d388f6aecb13eed52f46"
|
||||
integrity sha1-UlqcrU/LqWR119OI9q7LE+7VL0Y=
|
||||
dependencies:
|
||||
base64-js "^1.1.2"
|
||||
|
||||
browser-stdout@1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
|
||||
@ -1702,7 +1770,7 @@ colorspace@1.1.x:
|
||||
color "^3.1.3"
|
||||
text-hex "1.0.x"
|
||||
|
||||
combined-stream@^1.0.6, combined-stream@~1.0.6:
|
||||
combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
||||
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
|
||||
@ -2541,6 +2609,15 @@ forever-agent@~0.6.1:
|
||||
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
|
||||
integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
|
||||
|
||||
form-data@^2.5.0:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
|
||||
integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.6"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
form-data@~2.3.2:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
|
||||
@ -2877,6 +2954,15 @@ http-signature@~1.2.0:
|
||||
jsprim "^1.2.2"
|
||||
sshpk "^1.7.0"
|
||||
|
||||
http-signature@~1.3.1:
|
||||
version "1.3.6"
|
||||
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9"
|
||||
integrity sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
jsprim "^2.0.2"
|
||||
sshpk "^1.14.1"
|
||||
|
||||
http2-wrapper@^1.0.0-beta.5.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d"
|
||||
@ -3198,6 +3284,14 @@ isstream@~0.1.2:
|
||||
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
|
||||
integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
|
||||
|
||||
jira-client@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jira-client/-/jira-client-8.0.0.tgz#ce00c964f2aeed1f4a0bf8ed28bc3fe6f045216e"
|
||||
integrity sha512-vei9WuUboBAcQRxO6b5SwO5JXEyC1Y0ydiW4JiD7tPYc65wjXSbZvGDtQWsz7IOssFG/BQNAcJdiS79qVwnAJw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.6.0"
|
||||
postman-request "^2.88.1-postman.30"
|
||||
|
||||
js-tokens@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
@ -3247,6 +3341,11 @@ json-schema@0.2.3:
|
||||
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
|
||||
integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
|
||||
|
||||
json-schema@0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
|
||||
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
|
||||
|
||||
json-stable-stringify-without-jsonify@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||
@ -3312,6 +3411,16 @@ jsprim@^1.2.2:
|
||||
json-schema "0.2.3"
|
||||
verror "1.10.0"
|
||||
|
||||
jsprim@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d"
|
||||
integrity sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==
|
||||
dependencies:
|
||||
assert-plus "1.0.0"
|
||||
extsprintf "1.3.0"
|
||||
json-schema "0.4.0"
|
||||
verror "1.10.0"
|
||||
|
||||
just-diff-apply@^3.0.0:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/just-diff-apply/-/just-diff-apply-3.1.2.tgz#710d8cda00c65dc4e692df50dbe9bac5581c2193"
|
||||
@ -4375,6 +4484,34 @@ postcss@^8.3.11, postcss@^8.3.5:
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^0.6.2"
|
||||
|
||||
postman-request@^2.88.1-postman.30:
|
||||
version "2.88.1-postman.30"
|
||||
resolved "https://registry.yarnpkg.com/postman-request/-/postman-request-2.88.1-postman.30.tgz#44554fd3c19bf19e0a583e92b654d3c4f7e4669e"
|
||||
integrity sha512-zsGvs8OgNeno1Q44zTgGP2IL7kCqUy4DAtl8/ms0AQpqkIoysrxzR/Zg4kM1Kz8/duBvwxt8NN717wB7SMNm6w==
|
||||
dependencies:
|
||||
"@postman/form-data" "~3.1.1"
|
||||
"@postman/tunnel-agent" "^0.6.3"
|
||||
aws-sign2 "~0.7.0"
|
||||
aws4 "^1.8.0"
|
||||
brotli "~1.3.2"
|
||||
caseless "~0.12.0"
|
||||
combined-stream "~1.0.6"
|
||||
extend "~3.0.2"
|
||||
forever-agent "~0.6.1"
|
||||
har-validator "~5.1.3"
|
||||
http-signature "~1.3.1"
|
||||
is-typedarray "~1.0.0"
|
||||
isstream "~0.1.2"
|
||||
json-stringify-safe "~5.0.1"
|
||||
mime-types "~2.1.19"
|
||||
oauth-sign "~0.9.0"
|
||||
performance-now "^2.1.0"
|
||||
qs "~6.5.2"
|
||||
safe-buffer "^5.1.2"
|
||||
stream-length "^1.0.2"
|
||||
tough-cookie "~2.5.0"
|
||||
uuid "^3.3.2"
|
||||
|
||||
preact@^10.5.15:
|
||||
version "10.5.15"
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.5.15.tgz#6df94d8afecf3f9e10a742fd8c362ddab464225f"
|
||||
@ -4577,6 +4714,11 @@ reflect-metadata@^0.1.13:
|
||||
resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08"
|
||||
integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==
|
||||
|
||||
regenerator-runtime@^0.13.4:
|
||||
version "0.13.9"
|
||||
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
|
||||
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
|
||||
|
||||
regexpp@^3.2.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
|
||||
@ -4981,7 +5123,7 @@ sourcemap-codec@^1.4.4:
|
||||
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
|
||||
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
|
||||
|
||||
sshpk@^1.7.0:
|
||||
sshpk@^1.14.1, sshpk@^1.7.0:
|
||||
version "1.16.1"
|
||||
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
|
||||
integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
|
||||
@ -5030,6 +5172,13 @@ steno@^0.4.1:
|
||||
dependencies:
|
||||
graceful-fs "^4.1.3"
|
||||
|
||||
stream-length@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/stream-length/-/stream-length-1.0.2.tgz#8277f3cbee49a4daabcfdb4e2f4a9b5e9f2c9f00"
|
||||
integrity sha1-gnfzy+5JpNqrz9tOL0qbXp8snwA=
|
||||
dependencies:
|
||||
bluebird "^2.6.2"
|
||||
|
||||
string-argv@^0.3.1:
|
||||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
|
||||
|
Loading…
x
Reference in New Issue
Block a user