Add setup command for GitLab (#321)

* Cleanup gitlab auth command

* Add support for the `!hookshot gitlab project` command

* Improve docs

* changelog

* add new page

* Wording

Co-authored-by: Tadeusz Sośnierz <tadzik@tadzik.net>

* Remove useless comment

Co-authored-by: Tadeusz Sośnierz <tadzik@tadzik.net>
This commit is contained in:
Will Hunt 2022-04-26 16:04:24 +01:00 committed by GitHub
parent 4463e1c112
commit f56545bf7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 341 additions and 41 deletions

1
changelog.d/321.feature Normal file
View File

@ -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.

View File

@ -44,6 +44,7 @@ gitlab:
url: https://gitlab.com url: https://gitlab.com
webhook: webhook:
secret: secrettoken secret: secrettoken
publicUrl: https://example.com/hookshot/
userIdPrefix: userIdPrefix:
# (Optional) Prefix used when creating ghost users for GitLab accounts. # (Optional) Prefix used when creating ghost users for GitLab accounts.
# #

View File

@ -14,6 +14,7 @@
- [Authenticating](./usage/auth.md) - [Authenticating](./usage/auth.md)
- [Room Configuration](./usage/room_configuration.md) - [Room Configuration](./usage/room_configuration.md)
- [GitHub Repo](./usage/room_configuration/github_repo.md) - [GitHub Repo](./usage/room_configuration/github_repo.md)
- [GitLab Project](./usage/room_configuration/gitlab_project.md)
- [📊 Metrics](./metrics.md) - [📊 Metrics](./metrics.md)
# 🥼 Advanced # 🥼 Advanced

View File

@ -12,6 +12,7 @@ GitLab configuration is fairly straight-forward:
url: https://gitlab.com url: https://gitlab.com
webhook: webhook:
secret: secrettoken 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 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 You should generate a webhook `secret` (e.g. `pwgen -n 64 -s 1`) and then use this as your
"Secret token" when adding webhooks. "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
Adding a repository is a case of navigating to the settings page, and then adding a new webhook. 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 will want to give the URL of the public address for the hookshot webhooks port on the `/` path.
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 🥳.

View File

@ -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

View File

@ -346,12 +346,11 @@ export class AdminRoom extends AdminRoomCommandHandler {
} }
const instance = this.config.gitlab.instances[instanceName]; const instance = this.config.gitlab.instances[instanceName];
if (!instance) { 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 { try {
const client = new GitLabClient(instance.url, accessToken); const client = new GitLabClient(instance.url, accessToken);
me = await client.user(); me = await client.user();
client.issues
} catch (ex) { } catch (ex) {
log.error("Gitlab auth error:", ex); log.error("Gitlab auth error:", ex);
return this.sendNotice("Could not authenticate with GitLab. Is your token correct?"); return this.sendNotice("Could not authenticate with GitLab. Is your token correct?");

View File

@ -162,6 +162,7 @@ export interface GitLabInstance {
export interface BridgeConfigGitLabYAML { export interface BridgeConfigGitLabYAML {
webhook: { webhook: {
publicUrl?: string;
secret: string; secret: string;
}, },
instances: {[name: string]: GitLabInstance}; instances: {[name: string]: GitLabInstance};
@ -171,6 +172,7 @@ export interface BridgeConfigGitLabYAML {
export class BridgeConfigGitLab { export class BridgeConfigGitLab {
readonly instances: {[name: string]: GitLabInstance}; readonly instances: {[name: string]: GitLabInstance};
readonly webhook: { readonly webhook: {
publicUrl?: string;
secret: string; secret: string;
}; };
@ -182,6 +184,15 @@ export class BridgeConfigGitLab {
this.webhook = yaml.webhook; this.webhook = yaml.webhook;
this.userIdPrefix = yaml.userIdPrefix || "_gitlab_"; 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 { export interface BridgeConfigFeeds {

View File

@ -76,6 +76,7 @@ export const DefaultConfig = new BridgeConfig({
}, },
webhook: { webhook: {
secret: "secrettoken", secret: "secrettoken",
publicUrl: "https://example.com/hookshot/"
}, },
userIdPrefix: "_gitlab_", userIdPrefix: "_gitlab_",
}, },

View File

@ -6,15 +6,18 @@ import { BotCommands, botCommand, compileBotCommands } from "../BotCommands";
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import markdown from "markdown-it"; import markdown from "markdown-it";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { GitLabInstance } from "../Config/Config"; import { BridgeConfigGitLab, GitLabInstance } from "../Config/Config";
import { IGitLabNote, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; import { IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes";
import { CommandConnection } from "./CommandConnection"; import { CommandConnection } from "./CommandConnection";
import { IConnectionState } from "./IConnection"; import { IConnectionState } from "./IConnection";
import { GetConnectionsResponseItem } from "../provisioning/api";
import { ErrCode, ApiError } from "../api"
import { AccessLevel } from "../Gitlab/Types";
export interface GitLabRepoConnectionState extends IConnectionState { export interface GitLabRepoConnectionState extends IConnectionState {
instance: string; instance: string;
path: string; path: string;
ignoreHooks?: string[], ignoreHooks?: AllowedEventsNames[],
commandPrefix?: string; commandPrefix?: string;
pushTagsRegex?: string, pushTagsRegex?: string,
includingLabels?: string[]; includingLabels?: string[];
@ -26,6 +29,69 @@ const md = new markdown();
const MRRCOMMENT_DEBOUNCE_MS = 5000; const MRRCOMMENT_DEBOUNCE_MS = 5000;
export type GitLabRepoResponseItem = GetConnectionsResponseItem<GitLabRepoConnectionState, undefined>;
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<string, unknown>): 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. * Handles rooms connected to a github repo.
*/ */
@ -43,6 +109,55 @@ export class GitLabRepoConnection extends CommandConnection {
private readonly debounceMRComments = new Map<string, {comments: number, author: string, timeout: NodeJS.Timeout}>(); private readonly debounceMRComments = new Map<string, {comments: number, author: string, timeout: NodeJS.Timeout}>();
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, 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, constructor(roomId: string,
stateKey: string, stateKey: string,
private readonly as: Appservice, private readonly as: Appservice,
@ -81,6 +196,26 @@ export class GitLabRepoConnection extends CommandConnection {
return GitLabRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; 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) @botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true)
public async onCreateIssue(userId: string, title: string, description?: string, labels?: string) { public async onCreateIssue(userId: string, title: string, description?: string, labels?: string) {
const client = await this.tokenStore.getGitLabForUser(userId, this.instance.url); const client = await this.tokenStore.getGitLabForUser(userId, this.instance.url);
@ -130,7 +265,7 @@ export class GitLabRepoConnection extends CommandConnection {
public async onMergeRequestOpened(event: IGitLabWebhookMREvent) { public async onMergeRequestOpened(event: IGitLabWebhookMREvent) {
log.info(`onMergeRequestOpened ${this.roomId} ${this.path} #${event.object_attributes.iid}`); 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; return;
} }
this.validateMREvent(event); this.validateMREvent(event);
@ -146,7 +281,7 @@ export class GitLabRepoConnection extends CommandConnection {
public async onMergeRequestClosed(event: IGitLabWebhookMREvent) { public async onMergeRequestClosed(event: IGitLabWebhookMREvent) {
log.info(`onMergeRequestClosed ${this.roomId} ${this.path} #${event.object_attributes.iid}`); 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; return;
} }
this.validateMREvent(event); this.validateMREvent(event);
@ -162,7 +297,7 @@ export class GitLabRepoConnection extends CommandConnection {
public async onMergeRequestMerged(event: IGitLabWebhookMREvent) { public async onMergeRequestMerged(event: IGitLabWebhookMREvent) {
log.info(`onMergeRequestMerged ${this.roomId} ${this.path} #${event.object_attributes.iid}`); 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; return;
} }
this.validateMREvent(event); this.validateMREvent(event);
@ -177,7 +312,7 @@ export class GitLabRepoConnection extends CommandConnection {
} }
public async onMergeRequestReviewed(event: IGitLabWebhookMREvent) { 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; return;
} }
log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`); log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`);
@ -297,7 +432,7 @@ ${data.description}`;
} }
public async onCommentCreated(event: IGitLabWebhookNoteEvent) { 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; return;
} }
log.info(`onCommentCreated ${this.roomId} ${this.toString()} ${event.merge_request?.iid} ${event.object_attributes.id}`); log.info(`onCommentCreated ${this.roomId} ${this.toString()} ${event.merge_request?.iid} ${event.object_attributes.id}`);
@ -367,7 +502,7 @@ ${data.description}`;
return true; return true;
} }
private shouldSkipHook(...hookName: string[]) { private shouldSkipHook(...hookName: AllowedEventsNames[]) {
if (this.state.ignoreHooks) { if (this.state.ignoreHooks) {
for (const name of hookName) { for (const name of hookName) {
if (this.state.ignoreHooks?.includes(name)) { if (this.state.ignoreHooks?.includes(name)) {

View File

@ -14,6 +14,7 @@ import { FeedConnection } from "./FeedConnection";
import { URL } from "url"; import { URL } from "url";
import { SetupWidget } from "../Widgets/SetupWidget"; import { SetupWidget } from "../Widgets/SetupWidget";
import { AdminRoom } from "../AdminRoom"; import { AdminRoom } from "../AdminRoom";
import { GitLabRepoConnection } from "./GitlabRepo";
const md = new markdown(); 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}`); 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"}) @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) { public async onJiraProject(userId: string, urlStr: string) {
const url = new URL(urlStr); const url = new URL(urlStr);

View File

@ -1,11 +1,17 @@
import axios from "axios"; import axios from "axios";
import { GitLabInstance } from "../Config/Config"; 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 LogWrapper from "../LogWrapper";
import { URLSearchParams } from "url"; import { URLSearchParams } from "url";
import UserAgent from "../UserAgent"; import UserAgent from "../UserAgent";
const log = new LogWrapper("GitLabClient"); 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 { export class GitLabClient {
constructor(private instanceUrl: string, private token: string) { 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; return (await axios.put(`api/v4/projects/${opts.id}/issues/${opts.issue_iid}`, opts, this.defaultConfig)).data;
} }
private async getProject(projectParts: string[]): Promise<GetIssueResponse> {
private async getProject(id: ProjectId): Promise<GetProjectResponse> {
try { 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) { } catch (ex) {
log.warn(`Failed to get issue:`, ex); log.warn(`Failed to get project:`, ex);
throw ex;
}
}
private async getProjectHooks(id: ProjectId): Promise<ProjectHook[]> {
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<ProjectHook> {
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; throw ex;
} }
} }
@ -97,6 +122,10 @@ export class GitLabClient {
get projects() { get projects() {
return { return {
get: this.getProject.bind(this), get: this.getProject.bind(this),
hooks: {
list: this.getProjectHooks.bind(this),
add: this.addProjectHook.bind(this),
}
} }
} }

View File

@ -8,6 +8,18 @@ export interface GitLabAuthor {
web_url: string; 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 { export interface GetUserResponse {
id: number; id: number;
username: string; username: string;
@ -152,4 +164,58 @@ export interface CreateIssueNoteResponse {
noteable_iid: string; noteable_iid: string;
commands_changes: unknown; commands_changes: unknown;
} }
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;
}