diff --git a/changelog.d/647.feature b/changelog.d/647.feature new file mode 100644 index 00000000..eb795990 --- /dev/null +++ b/changelog.d/647.feature @@ -0,0 +1 @@ +Add support from migrating go-neb services to Hookshot diff --git a/src/Config/Config.ts b/src/Config/Config.ts index 4c010dfa..95b58092 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -431,6 +431,11 @@ export interface BridgeConfigMetrics { port?: number; } +export interface BridgeConfigGoNebMigrator { + apiUrl: string; + serviceIds?: string[]; +} + export interface BridgeConfigRoot { bot?: BridgeConfigBot; serviceBots?: BridgeConfigServiceBot[]; @@ -451,6 +456,7 @@ export interface BridgeConfigRoot { widgets?: BridgeWidgetConfigYAML; metrics?: BridgeConfigMetrics; listeners?: BridgeConfigListener[]; + goNebMigrator?: BridgeConfigGoNebMigrator; } export class BridgeConfig { @@ -503,6 +509,9 @@ export class BridgeConfig { 'resources' may be any of ${ResourceTypeArray.join(', ')}`, true) public readonly listeners: BridgeConfigListener[]; + @configKey("go-neb migrator configuration", true) + public readonly goNebMigrator?: BridgeConfigGoNebMigrator; + @hideKey() private readonly bridgePermissions: BridgePermissions; @@ -574,6 +583,8 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. this.queue.port = env.CFG_QUEUE_POST ? parseInt(env.CFG_QUEUE_POST, 10) : undefined; } + this.goNebMigrator = configData.goNebMigrator; + // Listeners is a bit special this.listeners = configData.listeners || []; diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 426b0963..b5a70887 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -94,7 +94,7 @@ export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRep export type GitHubRepoResponseItem = GetConnectionsResponseItem; -type AllowedEventsNames = +export type AllowedEventsNames = "issue.changed" | "issue.created" | "issue.edited" | @@ -119,7 +119,7 @@ type AllowedEventsNames = "workflow.run.action_required" | "workflow.run.stale"; -const AllowedEvents: AllowedEventsNames[] = [ +export const AllowedEvents: AllowedEventsNames[] = [ "issue.changed" , "issue.created" , "issue.edited" , diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index 0e714ef5..f29d8530 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -10,6 +10,8 @@ import { ConnectionManager } from "../ConnectionManager"; import BotUsersManager, {BotUser} from "../Managers/BotUsersManager"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api"; import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk"; +import { GoNebMigrator } from "./GoNebMigrator"; +import { StatusCodes } from "http-status-codes"; import { GithubInstance } from '../Github/GithubInstance'; import { AllowedTokenTypes, TokenType, UserTokenStore } from '../UserTokenStore'; @@ -17,6 +19,8 @@ const log = new Logger("BridgeWidgetApi"); export class BridgeWidgetApi { private readonly api: ProvisioningApi; + private readonly goNebMigrator?: GoNebMigrator; + constructor( private adminRooms: Map, private readonly config: BridgeConfig, @@ -61,9 +65,37 @@ export class BridgeWidgetApi { this.api.addRoute('get', '/v1/service/:service/auth', wrapHandler(this.getAuth)); this.api.addRoute('get', '/v1/service/:service/auth/:state', wrapHandler(this.getAuthPoll)); this.api.addRoute('post', '/v1/service/:service/auth/logout', wrapHandler(this.postAuthLogout)); + + if (this.config.goNebMigrator) { + this.goNebMigrator = new GoNebMigrator( + this.config.goNebMigrator.apiUrl, + this.config.goNebMigrator.serviceIds, + ); + } + + this.api.addRoute("get", "/v1/:roomId/goNebConnections", wrapHandler(this.getGoNebConnections)); } - private getBotUserInRoom(roomId: string, serviceType: string): BotUser { + private async getGoNebConnections(req: ProvisioningRequest, res: Response) { + if (!this.goNebMigrator) { + res.status(StatusCodes.NO_CONTENT).send(); + return; + } + + const roomId = req.params.roomId; + + if (!req.userId) { + throw Error('Cannot get connections without a valid userId'); + } + + const botUser = this.getBotUserInRoom(roomId); + await assertUserPermissionsInRoom(req.userId, roomId, "read", botUser.intent); + const connections = await this.goNebMigrator.getConnectionsForRoom(roomId, req.userId); + + res.send(connections); + } + + private getBotUserInRoom(roomId: string, serviceType?: string): BotUser { const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); if (!botUser) { throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); diff --git a/src/Widgets/GoNebMigrator.ts b/src/Widgets/GoNebMigrator.ts new file mode 100644 index 00000000..ce4fecbe --- /dev/null +++ b/src/Widgets/GoNebMigrator.ts @@ -0,0 +1,149 @@ +import { Logger } from "matrix-appservice-bridge"; +import axios from "axios"; + +import { FeedConnection, FeedConnectionState, GitHubRepoConnection, GitHubRepoConnectionState } from "../Connections"; +import { AllowedEvents as GitHubAllowedEvents, AllowedEventsNames as GitHubAllowedEventsNames } from "../Connections/GithubRepo"; + +const log = new Logger("GoNebMigrator"); + +interface MigratedGoNebConnection { + goNebId: string; +} + +type MigratedFeed = FeedConnectionState & MigratedGoNebConnection; +type MigratedGithub = GitHubRepoConnectionState & MigratedGoNebConnection; + +interface MigratedConnections { + [FeedConnection.ServiceCategory]: MigratedFeed[]|undefined, + [GitHubRepoConnection.ServiceCategory]: MigratedGithub[]|undefined; +} + +interface GoNebFeedsConfig { + [url: string]: { + rooms: string[], + } +} + +interface GoNebGithubRepos { + [githubPath: string]: { + Events: string[], // push, issues, pull_request, more? + } +} + +interface GoNebService { + Type: string; + Config: any; +} + +interface GoNebGithubWebhookService extends GoNebService { + Type: 'github-webhook'; + Config: { + ClientUserID: string; + Rooms: { + [roomId: string]: { Repos: GoNebGithubRepos; } + } + }; +} + +export class GoNebMigrator { + constructor( + private apiUrl: string, + private serviceIds?: string[], + ) {} + + static convertFeeds(goNebFeeds: GoNebFeedsConfig): Map { + const feedsPerRoom = new Map(); + + for (const [url, config] of Object.entries(goNebFeeds)) { + for (const roomId of config.rooms) { + const existing = feedsPerRoom.get(roomId) ?? []; + existing.push({ url }); + feedsPerRoom.set(roomId, existing); + } + } + + return feedsPerRoom; + } + + static convertGithub(roomRepos: GoNebGithubRepos): GitHubRepoConnectionState[] { + const eventMapping: { [goNebEvent: string]: GitHubAllowedEventsNames } = { + 'pull_request': 'pull_request', + 'issues': 'issue', + // 'push': ??? + }; + return Object.entries(roomRepos).map(([githubPath, { Events }]) => { + const [org, repo] = githubPath.split('/'); + const enableHooks = Events.map(goNebEvent => eventMapping[goNebEvent]).filter(e => !!e); + + return { + org, + repo, + enableHooks, + }; + }); + } + + public async getConnectionsForRoom(roomId: string, userId: string): Promise { + const feeds: MigratedFeed[] = []; + const github: MigratedGithub[] = []; + + const serviceIds = [ + ...(this.serviceIds ?? []), + ...['rssbot', 'github'].map(type => `${type}/${strictEncodeURIComponent(userId)}/${strictEncodeURIComponent(roomId)}`), + ]; + + for (const id of serviceIds) { + const endpoint = this.apiUrl + (this.apiUrl.endsWith('/') ? '' : '/') + 'admin/getService'; + let obj: GoNebService; + try { + const res = await axios.post(endpoint, { 'Id': id }); + obj = res.data as GoNebService; + } catch (err: unknown) { + if (axios.isAxiosError(err)) { + if (err.response?.status === 404) { + continue; + } + } + + throw err; + } + switch (obj.Type) { + case 'rssbot': { + const roomFeeds = GoNebMigrator.convertFeeds(obj.Config.feeds).get(roomId) ?? []; + const migratedFeeds = roomFeeds.map(f => ({ ...f, goNebId: id })); + feeds.push(...migratedFeeds); + break; + } + case 'github-webhook': { + const service = obj as GoNebGithubWebhookService; + if (service.Config.ClientUserID === userId) { + const roomRepos = service.Config.Rooms[roomId]?.Repos; + if (roomRepos) { + const githubConnections = GoNebMigrator.convertGithub(roomRepos); + const migratedGithubs = githubConnections.map(f => ({ ...f, goNebId: id })); + github.push(...migratedGithubs); + } + } + break; + } + default: { + log.warn(`Unrecognized go-neb service type (${obj.Type}), skipping`); + } + } + } + + return { + feeds, + github, + }; + } +} + +// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986 +function strictEncodeURIComponent(str: string) { + return encodeURIComponent(str) + .replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ); +} diff --git a/web/BridgeAPI.ts b/web/BridgeAPI.ts index 83dbbd56..d30dd577 100644 --- a/web/BridgeAPI.ts +++ b/web/BridgeAPI.ts @@ -118,6 +118,10 @@ export class BridgeAPI { return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections`); } + async getGoNebConnectionsForRoom(roomId: string): Promise { + return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/goNebConnections`); + } + async getConnectionsForService(roomId: string, service: string): Promise> { return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(service)}`); } diff --git a/web/components/roomConfig/FeedsConfig.tsx b/web/components/roomConfig/FeedsConfig.tsx index 7cf23b81..96c9931a 100644 --- a/web/components/roomConfig/FeedsConfig.tsx +++ b/web/components/roomConfig/FeedsConfig.tsx @@ -1,5 +1,5 @@ import { FunctionComponent, createRef } from "preact"; -import { useCallback } from "preact/hooks" +import { useCallback, useEffect, useState } from "preact/hooks" import { BridgeConfig } from "../../BridgeAPI"; import { FeedConnectionState, FeedResponseItem } from "../../../src/Connections/FeedConnection"; import { ConnectionConfigurationProps, IRoomConfigText, RoomConfig } from "./RoomConfig"; @@ -29,7 +29,7 @@ const ConnectionConfiguration: FunctionComponent(); const labelRef = createRef(); - const canEdit = !existingConnection || (existingConnection?.canEdit ?? false); + const canEdit = !existingConnection?.id || (existingConnection?.canEdit ?? false); const handleSave = useCallback((evt: Event) => { evt.preventDefault(); if (!canEdit) { @@ -47,16 +47,16 @@ const ConnectionConfiguration: FunctionComponent { existingConnection && } - - + + - + - { canEdit && } - { canEdit && existingConnection && } + { canEdit && } + { canEdit && existingConnection?.id && } ; @@ -76,6 +76,21 @@ const roomConfigText: IRoomConfigText = { const RoomConfigListItemFunc = (c: FeedResponseItem) => c.config.label || c.config.url; export const FeedsConfig: BridgeConfig = ({ api, roomId, showHeader }) => { + const [ goNebConnections, setGoNebConnections ] = useState(undefined); + + useEffect(() => { + api.getGoNebConnectionsForRoom(roomId).then((res: any) => { + if (!res) return; + setGoNebConnections(res.feeds.map((config: any) => ({ + config, + }))); + }).catch(ex => { + console.warn("Failed to fetch go neb connections", ex); + }); + }, [api, roomId]); + + const compareConnections = useCallback((goNebConnection, nativeConnection) => goNebConnection.config.url === nativeConnection.config.url, []); + return headerImg={FeedsIcon} showHeader={showHeader} @@ -86,5 +101,7 @@ export const FeedsConfig: BridgeConfig = ({ api, roomId, showHeader }) => { text={roomConfigText} listItemName={RoomConfigListItemFunc} connectionConfigComponent={ConnectionConfiguration} + migrationCandidates={goNebConnections} + migrationComparator={compareConnections} />; }; diff --git a/web/components/roomConfig/GithubRepoConfig.tsx b/web/components/roomConfig/GithubRepoConfig.tsx index 6310d1ee..16fa2669 100644 --- a/web/components/roomConfig/GithubRepoConfig.tsx +++ b/web/components/roomConfig/GithubRepoConfig.tsx @@ -59,7 +59,7 @@ const ConnectionConfiguration: FunctionComponent(null); - const canEdit = !existingConnection || (existingConnection?.canEdit ?? false); + const canEdit = !existingConnection?.id || (existingConnection?.canEdit ?? false); const commandPrefixRef = createRef(); const handleSave = useCallback((evt: Event) => { evt.preventDefault(); @@ -152,8 +152,8 @@ const ConnectionConfiguration: FunctionComponent - { canEdit && authedResponse?.authenticated && } - { canEdit && existingConnection && } + { canEdit && authedResponse?.authenticated && } + { canEdit && existingConnection?.id && } ; }; @@ -169,6 +169,25 @@ const roomConfigText: IRoomConfigText = { const RoomConfigListItemFunc = (c: GitHubRepoResponseItem) => getRepoFullName(c.config); export const GithubRepoConfig: BridgeConfig = ({ api, roomId, showHeader }) => { + const [ goNebConnections, setGoNebConnections ] = useState(undefined); + + useEffect(() => { + api.getGoNebConnectionsForRoom(roomId).then((res: any) => { + if (!res) return; + setGoNebConnections(res.github.map((config: any) => ({ + config, + }))); + }).catch(ex => { + console.warn("Failed to fetch go neb connections", ex); + }); + }, [api, roomId]); + + const compareConnections = useCallback( + (goNebConnection, nativeConnection) => goNebConnection.config.org === nativeConnection.config.org + && goNebConnection.config.repo === nativeConnection.config.repo, + [] + ); + return headerImg={GitHubIcon} showHeader={showHeader} @@ -180,5 +199,7 @@ export const GithubRepoConfig: BridgeConfig = ({ api, roomId, showHeader }) => { listItemName={RoomConfigListItemFunc} connectionEventType={EventType} connectionConfigComponent={ConnectionConfiguration} + migrationCandidates={goNebConnections} + migrationComparator={compareConnections} />; }; diff --git a/web/components/roomConfig/RoomConfig.tsx b/web/components/roomConfig/RoomConfig.tsx index b63403c5..e1e337dc 100644 --- a/web/components/roomConfig/RoomConfig.tsx +++ b/web/components/roomConfig/RoomConfig.tsx @@ -37,6 +37,8 @@ interface IRoomConfigProps string, connectionConfigComponent: FunctionComponent>; + migrationCandidates?: ConnectionType[]; + migrationComparator?: (migrated: ConnectionType, native: ConnectionType) => boolean; } export const RoomConfig = function(props: IRoomConfigProps) { const { @@ -48,7 +50,9 @@ export const RoomConfig = function(null); @@ -76,6 +80,26 @@ export const RoomConfig = function([]); + + useEffect(() => { + // produce `toMigrate` composed of `migrationCandidates` with anything already in `connections` filtered out + // use `migrationComparator` to determine duplicates + if (!migrationCandidates) { + setToMigrate([]); + return; + } + + if (!connections || !migrationComparator) { + setToMigrate(migrationCandidates); + return; + } + + setToMigrate( + migrationCandidates.filter(cand => !connections.find(c => migrationComparator(cand, c))) + ); + }, [ connections, migrationCandidates, migrationComparator ]); + useEffect(() => { api.getServiceConfig(type) .then(setServiceConfig) @@ -170,8 +194,19 @@ export const RoomConfig = function ) - } - } + } + } + { toMigrate.length > 0 &&
+

Migrate connections

+ { serviceConfig && toMigrate.map(c => + + ) } +
} ; };