diff --git a/changelog.d/321.feature b/changelog.d/321.feature new file mode 100644 index 00000000..e1320f8e --- /dev/null +++ b/changelog.d/321.feature @@ -0,0 +1 @@ +Add new `!hookshot gitlab project` command to configure project bridges in rooms. See [the docs](https://matrix-org.github.io/matrix-hookshot/latest/usage/room_configuration/gitlab_project.html) for instructions. \ No newline at end of file diff --git a/config.sample.yml b/config.sample.yml index 29ad40c9..3a186a86 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -44,6 +44,7 @@ gitlab: url: https://gitlab.com webhook: secret: secrettoken + publicUrl: https://example.com/hookshot/ userIdPrefix: # (Optional) Prefix used when creating ghost users for GitLab accounts. # diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 79ed8759..d48a2c16 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -14,6 +14,7 @@ - [Authenticating](./usage/auth.md) - [Room Configuration](./usage/room_configuration.md) - [GitHub Repo](./usage/room_configuration/github_repo.md) + - [GitLab Project](./usage/room_configuration/gitlab_project.md) - [📊 Metrics](./metrics.md) # 🥼 Advanced diff --git a/docs/setup/gitlab.md b/docs/setup/gitlab.md index d0292298..7c5ae0e1 100644 --- a/docs/setup/gitlab.md +++ b/docs/setup/gitlab.md @@ -12,6 +12,7 @@ GitLab configuration is fairly straight-forward: url: https://gitlab.com webhook: secret: secrettoken + publicUrl: https://example.com/webhooks/ ``` You need to list all the instances you plan to connect to in the `config.yml`. This is @@ -21,31 +22,10 @@ to specify an instance. You should generate a webhook `secret` (e.g. `pwgen -n 64 -s 1`) and then use this as your "Secret token" when adding webhooks. +The `publicUrl` must be the URL where GitLab webhook events are received (i.e. the path to `/` +for your `webhooks` listener). + ## Adding a repository -Adding a repository is a case of navigating to the settings page, and then adding a new webhook. -You will want to give the URL of the public address for the hookshot webhooks port on the `/` path. +You can now follow the guide on [authenticating with GitLab](../usage/auth.md), and then [bridging a room](../usage/room_configuration/gitlab_project.md#setting-up) -You should use the value of `webhook.secret` from your config as your "Secret token". - -You should add the events you wish to trigger on. Hookshot currently supports: - -- Push events -- Tag events -- Issues events -- Merge request events -- Releases events - -You will need to do this each time you want to a repository to hookshot. - -To then bridge a room to GitLab, you will need to add a `uk.half-shot.matrix-hookshot.gitlab.repository` - *state event* to a room containing a content of: - -```json5 -{ - "instance": "gitlab", // your instance name - "path": "yourusername/repo" // the full path to the repo -} -``` - -Once this is done, you are bridged 🥳. diff --git a/docs/usage/room_configuration/gitlab_project.md b/docs/usage/room_configuration/gitlab_project.md new file mode 100644 index 00000000..07481fb6 --- /dev/null +++ b/docs/usage/room_configuration/gitlab_project.md @@ -0,0 +1,48 @@ +GitLab Project +================= + +This connection type connects a GitLab project (e.g. https://gitlab.matrix.org/matrix-org/olm) to a room. + +## Setting up + +To set up a connection to a GitLab project in a new room: + +(NB you must have permission to bridge GitLab repositories before you can use this command, see [auth](../auth.html#gitlab).) + +1. Create a new, unencrypted room. It can be public or private. +2. Invite the bridge bot (e.g. `@hookshot:example.com`). +3. Give the bridge bot moderator permissions or higher (power level 50) (or otherwise configure the room so the bot can edit room state). +4. Send the command `!hookshot gitlab project https://mydomain/my/project`. +5. If you have permission to bridge this repo, the bridge will respond with a confirmation message. (Users with `Developer` permissions or greater can bridge projects.) + 6. If you have configured the bridge with a `publicUrl` inside `gitlab.webhook`, it will automatically provision the webhook for you. + 7. Otherwise, you'll need to manually configure the webhook to point to your public address for the webhooks listener. + +## Configuration + +This connection supports a few options which can be defined in the room state: + +| Option | Description | Allowed values | Default | +|--------|-------------|----------------|---------| +|ignoreHooks|Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| +|commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!gh`| +|pushTagsRegex|Only mention pushed tags which match this regex|Regex string|*empty*| +|prDiff|Show a diff in the room when a PR is created, subject to limits|`{enabled: boolean, maxLines: number}`|`{enabled: false}`| +|includingLabels|Only notify on issues matching these label names|Array of: String matching a label name|*empty*| +|excludingLabels|Never notify on issues matching these label names|Array of: String matching a label name|*empty*| + + +### Supported event types + +This connection supports sending messages when the following actions happen on the repository. + +- merge_request + - merge_request.close + - merge_request.merge + - merge_request.open + - merge_request.review.comments + - merge_request.review +- push +- release + - release.created +- tag_push +- wiki diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index 110accb6..b977e049 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -346,12 +346,11 @@ export class AdminRoom extends AdminRoomCommandHandler { } const instance = this.config.gitlab.instances[instanceName]; if (!instance) { - return this.sendNotice("The bridge is not configured for this GitLab instance."); + return this.sendNotice("The bridge is not configured for this GitLab instance. Ask your administrator for a list of instances."); } try { const client = new GitLabClient(instance.url, accessToken); me = await client.user(); - client.issues } catch (ex) { log.error("Gitlab auth error:", ex); return this.sendNotice("Could not authenticate with GitLab. Is your token correct?"); diff --git a/src/Config/Config.ts b/src/Config/Config.ts index b0395f81..767c6f22 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -162,6 +162,7 @@ export interface GitLabInstance { export interface BridgeConfigGitLabYAML { webhook: { + publicUrl?: string; secret: string; }, instances: {[name: string]: GitLabInstance}; @@ -171,6 +172,7 @@ export interface BridgeConfigGitLabYAML { export class BridgeConfigGitLab { readonly instances: {[name: string]: GitLabInstance}; readonly webhook: { + publicUrl?: string; secret: string; }; @@ -182,6 +184,15 @@ export class BridgeConfigGitLab { this.webhook = yaml.webhook; this.userIdPrefix = yaml.userIdPrefix || "_gitlab_"; } + + public getInstanceByProjectUrl(url: string): {name: string, instance: GitLabInstance}|null { + for (const [name, instance] of Object.entries(this.instances)) { + if (url.startsWith(instance.url)) { + return {name, instance}; + } + } + return null; + } } export interface BridgeConfigFeeds { diff --git a/src/Config/Defaults.ts b/src/Config/Defaults.ts index 41c1add0..e448a4d0 100644 --- a/src/Config/Defaults.ts +++ b/src/Config/Defaults.ts @@ -76,6 +76,7 @@ export const DefaultConfig = new BridgeConfig({ }, webhook: { secret: "secrettoken", + publicUrl: "https://example.com/hookshot/" }, userIdPrefix: "_gitlab_", }, diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 7a5838af..c356280e 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -6,15 +6,18 @@ import { BotCommands, botCommand, compileBotCommands } from "../BotCommands"; import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import markdown from "markdown-it"; import LogWrapper from "../LogWrapper"; -import { GitLabInstance } from "../Config/Config"; -import { IGitLabNote, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; +import { BridgeConfigGitLab, GitLabInstance } from "../Config/Config"; +import { IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; import { CommandConnection } from "./CommandConnection"; import { IConnectionState } from "./IConnection"; +import { GetConnectionsResponseItem } from "../provisioning/api"; +import { ErrCode, ApiError } from "../api" +import { AccessLevel } from "../Gitlab/Types"; export interface GitLabRepoConnectionState extends IConnectionState { instance: string; path: string; - ignoreHooks?: string[], + ignoreHooks?: AllowedEventsNames[], commandPrefix?: string; pushTagsRegex?: string, includingLabels?: string[]; @@ -26,6 +29,69 @@ const md = new markdown(); const MRRCOMMENT_DEBOUNCE_MS = 5000; + +export type GitLabRepoResponseItem = GetConnectionsResponseItem; + + +type AllowedEventsNames = + "merge_request.open" | + "merge_request.close" | + "merge_request.merge" | + "merge_request.review" | + "merge_request.review.comments" | + `merge_request.${string}` | + "merge_request" | + "tag_push" | + "push" | + "wiki" | + `wiki.${string}` | + "release" | + "release.created"; + +const AllowedEvents: AllowedEventsNames[] = [ + "merge_request.open", + "merge_request.close", + "merge_request.merge", + "merge_request.review", + "merge_request.review.comments", + "merge_request", + "tag_push", + "push", + "wiki", + "release", + "release.created", +]; + + +function validateState(state: Record): GitLabRepoConnectionState { + if (typeof state.instance !== "string") { + throw new ApiError("Expected a 'instance' property", ErrCode.BadValue); + } + if (typeof state.path !== "string") { + throw new ApiError("Expected a 'path' property", ErrCode.BadValue); + } + const res: GitLabRepoConnectionState = { + instance: state.instance, + path: state.path, + } + if (state.commandPrefix) { + if (typeof state.commandPrefix !== "string") { + throw new ApiError("Expected 'commandPrefix' to be a string", ErrCode.BadValue); + } + if (state.commandPrefix.length < 2 || state.commandPrefix.length > 24) { + throw new ApiError("Expected 'commandPrefix' to be between 2-24 characters", ErrCode.BadValue); + } + res.commandPrefix = state.commandPrefix; + } + if (state.ignoreHooks && Array.isArray(state.ignoreHooks)) { + if (state.ignoreHooks?.find((ev) => !AllowedEvents.includes(ev))?.length) { + throw new ApiError(`'events' can only contain ${AllowedEvents.join(", ")}`, ErrCode.BadValue); + } + res.ignoreHooks = state.ignoreHooks; + } + return res; +} + /** * Handles rooms connected to a github repo. */ @@ -43,6 +109,55 @@ export class GitLabRepoConnection extends CommandConnection { private readonly debounceMRComments = new Map(); + public static async provisionConnection(roomId: string, requester: string, data: Record, as: Appservice, tokenStore: UserTokenStore, instance: GitLabInstance, gitlabConfig: BridgeConfigGitLab) { + const validData = validateState(data); + const client = await tokenStore.getGitLabForUser(requester, instance.url); + if (!client) { + throw new ApiError("User is not authenticated with GitLab", ErrCode.ForbiddenUser); + } + let permissionLevel; + let project; + try { + project = await client.projects.get(validData.path); + permissionLevel = Math.max(project.permissions.group_access?.access_level || 0, project.permissions.project_access?.access_level || 0) as AccessLevel; + } catch (ex) { + throw new ApiError("Could not determine if the user has access to this project, does the project exist?", ErrCode.ForbiddenUser); + } + + if (permissionLevel < AccessLevel.Developer) { + throw new ApiError("You must at least have developer access to bridge this project", ErrCode.ForbiddenUser); + } + + // Try to setup a webhook + if (gitlabConfig.webhook.publicUrl) { + const hooks = await client.projects.hooks.list(project.id); + const hasHook = hooks.find(h => h.url === gitlabConfig.webhook.publicUrl); + if (!hasHook) { + log.info(`Creating webhook for ${validData.path}`); + await client.projects.hooks.add(project.id, { + url: gitlabConfig.webhook.publicUrl, + token: gitlabConfig.webhook.secret, + enable_ssl_verification: true, + // TODO: Determine which of these actually interests the user. + issues_events: true, + merge_requests_events: true, + push_events: true, + releases_events: true, + tag_push_events: true, + wiki_page_events: true, + }); + } + } else { + log.info(`Not creating webhook, webhookUrl is not defined in config`); + } + + const stateEventKey = `${validData.instance}/${validData.path}`; + return { + stateEventContent: validData, + connection: new GitLabRepoConnection(roomId, stateEventKey, as, validData, tokenStore, instance), + } + } + constructor(roomId: string, stateKey: string, private readonly as: Appservice, @@ -81,6 +196,26 @@ export class GitLabRepoConnection extends CommandConnection { return GitLabRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; } + public static getProvisionerDetails(botUserId: string) { + return { + service: "gitlab", + eventType: GitLabRepoConnection.CanonicalEventType, + type: "GitLabRepo", + // TODO: Add ability to configure the bot per connnection type. + botUserId: botUserId, + } + } + + public getProvisionerDetails() { + return { + ...GitLabRepoConnection.getProvisionerDetails(this.as.botUserId), + id: this.connectionId, + config: { + ...this.state, + }, + } + } + @botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true) public async onCreateIssue(userId: string, title: string, description?: string, labels?: string) { const client = await this.tokenStore.getGitLabForUser(userId, this.instance.url); @@ -130,7 +265,7 @@ export class GitLabRepoConnection extends CommandConnection { public async onMergeRequestOpened(event: IGitLabWebhookMREvent) { log.info(`onMergeRequestOpened ${this.roomId} ${this.path} #${event.object_attributes.iid}`); - if (this.shouldSkipHook('merge_request.open') || !this.matchesLabelFilter(event)) { + if (this.shouldSkipHook('merge_request', 'merge_request.open') || !this.matchesLabelFilter(event)) { return; } this.validateMREvent(event); @@ -146,7 +281,7 @@ export class GitLabRepoConnection extends CommandConnection { public async onMergeRequestClosed(event: IGitLabWebhookMREvent) { log.info(`onMergeRequestClosed ${this.roomId} ${this.path} #${event.object_attributes.iid}`); - if (this.shouldSkipHook('merge_request.close') || !this.matchesLabelFilter(event)) { + if (this.shouldSkipHook('merge_request', 'merge_request.close') || !this.matchesLabelFilter(event)) { return; } this.validateMREvent(event); @@ -162,7 +297,7 @@ export class GitLabRepoConnection extends CommandConnection { public async onMergeRequestMerged(event: IGitLabWebhookMREvent) { log.info(`onMergeRequestMerged ${this.roomId} ${this.path} #${event.object_attributes.iid}`); - if (this.shouldSkipHook('merge_request.merge') || !this.matchesLabelFilter(event)) { + if (this.shouldSkipHook('merge_request', 'merge_request.merge') || !this.matchesLabelFilter(event)) { return; } this.validateMREvent(event); @@ -177,7 +312,7 @@ export class GitLabRepoConnection extends CommandConnection { } public async onMergeRequestReviewed(event: IGitLabWebhookMREvent) { - if (this.shouldSkipHook('merge_request.review', `merge_request.${event.object_attributes.action}`) || !this.matchesLabelFilter(event)) { + if (this.shouldSkipHook('merge_request', 'merge_request.review', `merge_request.${event.object_attributes.action}`) || !this.matchesLabelFilter(event)) { return; } log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`); @@ -297,7 +432,7 @@ ${data.description}`; } public async onCommentCreated(event: IGitLabWebhookNoteEvent) { - if (this.shouldSkipHook('merge_request.review', 'merge_request.review.comments')) { + if (this.shouldSkipHook('merge_request', 'merge_request.review', 'merge_request.review.comments')) { return; } log.info(`onCommentCreated ${this.roomId} ${this.toString()} ${event.merge_request?.iid} ${event.object_attributes.id}`); @@ -367,7 +502,7 @@ ${data.description}`; return true; } - private shouldSkipHook(...hookName: string[]) { + private shouldSkipHook(...hookName: AllowedEventsNames[]) { if (this.state.ignoreHooks) { for (const name of hookName) { if (this.state.ignoreHooks?.includes(name)) { diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 03edee04..92c9fc59 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -14,6 +14,7 @@ import { FeedConnection } from "./FeedConnection"; import { URL } from "url"; import { SetupWidget } from "../Widgets/SetupWidget"; import { AdminRoom } from "../AdminRoom"; +import { GitLabRepoConnection } from "./GitlabRepo"; const md = new markdown(); /** @@ -74,6 +75,33 @@ export class SetupConnection extends CommandConnection { await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${org}/${repo}`); } + @botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: "gitlab"}) + public async onGitLabRepo(userId: string, url: string) { + if (!this.config.gitlab) { + throw new CommandError("not-configured", "The bridge is not configured to support GitLab."); + } + url = url.toLowerCase(); + + await this.checkUserPermissions(userId, "gitlab", GitLabRepoConnection.CanonicalEventType); + + const {name, instance} = this.config.gitlab.getInstanceByProjectUrl(url) || {}; + if (!instance || !name) { + throw new CommandError("not-configured", "No instance found that matches the provided URL."); + } + + const client = await this.tokenStore.getGitLabForUser(userId, instance.url); + if (!client) { + throw new CommandError("User not logged in", "You are not logged into this GitLab instance. Start a DM with this bot and use the command `gitlab personaltoken`."); + } + const path = url.slice(instance.url.length + 1); + if (!path) { + throw new CommandError("Invalid GitLab url", "The GitLab project url you entered was not valid."); + } + const res = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.as, this.tokenStore, instance, this.config.gitlab); + await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, url, res.stateEventContent); + await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${path}`); + } + @botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: "jira"}) public async onJiraProject(userId: string, urlStr: string) { const url = new URL(urlStr); diff --git a/src/Gitlab/Client.ts b/src/Gitlab/Client.ts index 16b6c21d..51d1c11f 100644 --- a/src/Gitlab/Client.ts +++ b/src/Gitlab/Client.ts @@ -1,11 +1,17 @@ import axios from "axios"; import { GitLabInstance } from "../Config/Config"; -import { GetIssueResponse, GetUserResponse, CreateIssueOpts, CreateIssueResponse, GetIssueOpts, EditIssueOpts, GetTodosResponse, EventsOpts, CreateIssueNoteOpts, CreateIssueNoteResponse } from "./Types"; +import { GetIssueResponse, GetUserResponse, CreateIssueOpts, CreateIssueResponse, GetIssueOpts, EditIssueOpts, GetTodosResponse, EventsOpts, CreateIssueNoteOpts, CreateIssueNoteResponse, GetProjectResponse, ProjectHook, ProjectHookOpts } from "./Types"; import LogWrapper from "../LogWrapper"; import { URLSearchParams } from "url"; import UserAgent from "../UserAgent"; const log = new LogWrapper("GitLabClient"); + +type ProjectId = string|number|string[]; + +function getProjectId(id: ProjectId) { + return encodeURIComponent(Array.isArray(id) ? id.join("/") : id); +} export class GitLabClient { constructor(private instanceUrl: string, private token: string) { @@ -55,11 +61,30 @@ export class GitLabClient { return (await axios.put(`api/v4/projects/${opts.id}/issues/${opts.issue_iid}`, opts, this.defaultConfig)).data; } - private async getProject(projectParts: string[]): Promise { + + private async getProject(id: ProjectId): Promise { try { - return (await axios.get(`api/v4/projects/${projectParts.join("%2F")}`, this.defaultConfig)).data; + return (await axios.get(`api/v4/projects/${getProjectId(id)}`, this.defaultConfig)).data; } catch (ex) { - log.warn(`Failed to get issue:`, ex); + log.warn(`Failed to get project:`, ex); + throw ex; + } + } + + private async getProjectHooks(id: ProjectId): Promise { + try { + return (await axios.get(`api/v4/projects/${getProjectId(id)}/hooks`, this.defaultConfig)).data; + } catch (ex) { + log.warn(`Failed to get project hooks:`, ex); + throw ex; + } + } + + private async addProjectHook(id: ProjectId, opts: ProjectHookOpts): Promise { + try { + return (await axios.post(`api/v4/projects/${getProjectId(id)}/hooks`, opts, this.defaultConfig)).data; + } catch (ex) { + log.warn(`Failed to create project hook:`, ex); throw ex; } } @@ -97,6 +122,10 @@ export class GitLabClient { get projects() { return { get: this.getProject.bind(this), + hooks: { + list: this.getProjectHooks.bind(this), + add: this.addProjectHook.bind(this), + } } } diff --git a/src/Gitlab/Types.ts b/src/Gitlab/Types.ts index a4c09838..757c0ec3 100644 --- a/src/Gitlab/Types.ts +++ b/src/Gitlab/Types.ts @@ -8,6 +8,18 @@ export interface GitLabAuthor { web_url: string; } +// https://docs.gitlab.com/ee/api/access_requests.html#valid-access-levels +export enum AccessLevel { + NoAccess = 0, + MinimalAccess = 5, + Guest = 10, + Reporter = 20, + Developer = 30, + Maintainer = 40, + // Only valid to set for groups + Owner = 50, +} + export interface GetUserResponse { id: number; username: string; @@ -152,4 +164,58 @@ export interface CreateIssueNoteResponse { noteable_iid: string; commands_changes: unknown; } - \ No newline at end of file + +export interface GetProjectResponse { + id: number; + name: string; + path_with_namespace: string; + title: string; + description: string; + visibility: "private"|"internal"|"public", + state: 'opened'|'closed'; + owner: { + id: number; + name: string; + }; + permissions: { + project_access?: { + access_level: AccessLevel; + }, + group_access?: { + access_level: AccessLevel; + } + }; + author: GitLabAuthor; + references: { + short: string; + relative: string; + full: string; + } + web_url: string; +} + +export interface ProjectHookOpts { + url: string; + token: string; + push_events?: true; + push_events_branch_filter?: string; + issues_events?: boolean; + confidential_issues_events?: boolean; + merge_requests_events?: boolean; + tag_push_events?: boolean; + note_events?: boolean; + confidential_note_events?: boolean; + job_events?: boolean; + pipeline_events?: boolean; + wiki_page_events?: boolean; + deployment_events?: boolean; + releases_events?: boolean; + enable_ssl_verification?: boolean; +} + +export interface ProjectHook extends ProjectHookOpts { + id: number; + token: never; + project_id: 3; + created_at?: string; +}