diff --git a/src/Bridge.ts b/src/Bridge.ts index eba0ff4a..38704b38 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -133,7 +133,7 @@ export class Bridge { } if (this.config.github) { routers.push({ - route: "/v1/jira", + route: "/v1/github", router: new GitHubProvisionerRouter(this.config.github, this.tokenStore).getRouter(), }); } diff --git a/src/Github/Router.ts b/src/Github/Router.ts index 5f92b24d..a39ce734 100644 --- a/src/Github/Router.ts +++ b/src/Github/Router.ts @@ -1,8 +1,30 @@ -import { Router, Request, Response } from "express"; +import { Router, Request, Response, NextFunction } from "express"; import { BridgeConfigGitHub } from "../Config/Config"; import { ApiError, ErrCode } from "../provisioning/api"; import { UserTokenStore } from "../UserTokenStore"; import { generateGitHubOAuthUrl } from "./AdminCommands"; +import LogWrapper from "../LogWrapper"; + +const log = new LogWrapper("GitHubProvisionerRouter"); +interface GitHubAccountStatus { + loggedIn: boolean; + organisations?: { + name: string; + avatarUrl: string; + }[] +} +interface GitHubRepoItem { + name: string; + owner: string; + fullName: string; + description: string|null; + avatarUrl: string; +} + +interface GitHubRepoResponse { + page: number; + repositories: GitHubRepoItem[]; +} export class GitHubProvisionerRouter { constructor(private readonly config: BridgeConfigGitHub, private readonly tokenStore: UserTokenStore) { } @@ -10,6 +32,9 @@ export class GitHubProvisionerRouter { public getRouter() { const router = Router(); router.get("/oauth", this.onOAuth.bind(this)); + router.get("/account", this.onGetAccount.bind(this)); + router.get("/orgs/:orgName/repositories", this.onGetOrgRepositories.bind(this)); + router.get("/repositories", this.onGetRepositories.bind(this)); return router; } @@ -21,4 +46,96 @@ export class GitHubProvisionerRouter { url: generateGitHubOAuthUrl(this.config.oauth.client_id, this.config.oauth.redirect_uri, this.tokenStore.createStateForOAuth(req.query.userId)) }); } + + private async onGetAccount(req: Request, res: Response, next: NextFunction) { + const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); + if (!octokit) { + return res.send({ + loggedIn: false, + }); + } + const organisations = []; + const page = req.query.page ? parseInt(req.query.page) : 1; + const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; + try { + const orgRes = await octokit.orgs.listForAuthenticatedUser({page, per_page: perPage}); + for (const org of orgRes.data) { + organisations.push({ + name: org.login, + avatarUrl: org.avatar_url, + }); + } + } catch (ex) { + log.warn(`Failed to fetch orgs for GitHub user ${req.query.userId}`, ex); + return next( new ApiError("Could not fetch orgs for GitHub user", ErrCode.Unknown)); + } + return res.send({ + loggedIn: true, + organisations, + }) + } + + private async onGetOrgRepositories(req: Request<{orgName: string}, undefined, undefined, {userId: string, page: string, perPage: string}>, res: Response, next: NextFunction) { + const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); + if (!octokit) { + // TODO: Better error? + return next(new ApiError("Not logged in", ErrCode.ForbiddenUser)); + } + + const repositories = []; + const page = req.query.page ? parseInt(req.query.page) : 1; + const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; + try { + const orgRes = await octokit.repos.listForOrg({org: req.params.orgName, page, per_page: perPage}); + for (const repo of orgRes.data) { + repositories.push({ + name: repo.name, + owner: repo.owner.login, + fullName: repo.full_name, + description: repo.description, + avatarUrl: repo.owner.avatar_url, + }); + } + + return res.send({ + page, + repositories, + }); + } catch (ex) { + log.warn(`Failed to fetch accessible repos for ${req.params.orgName} / ${req.query.userId}`, ex); + return next(new ApiError("Could not fetch accessible repos for GitHub org", ErrCode.Unknown)); + } + } + + private async onGetRepositories(req: Request, res: Response, next: NextFunction) { + const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); + if (!octokit) { + // TODO: Better error? + return next(new ApiError("Not logged in", ErrCode.ForbiddenUser)); + } + + const repositories = []; + const page = req.query.page ? parseInt(req.query.page) : 1; + const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; + try { + const orgRes = await octokit.repos.listForAuthenticatedUser({page, per_page: perPage, type: "member"}); + for (const repo of orgRes.data) { + repositories.push({ + name: repo.name, + owner: repo.owner.login, + fullName: repo.full_name, + description: repo.description, + avatarUrl: repo.owner.avatar_url, + }); + } + + return res.send({ + page, + repositories, + }); + } catch (ex) { + log.warn(`Failed to fetch accessible repos for ${req.query.userId}`, ex); + return next(new ApiError("Could not fetch accessible repos for GitHub user", ErrCode.Unknown)); + } + } } diff --git a/src/provisioning/api.md b/src/provisioning/api.md index f9fa653e..7986b545 100644 --- a/src/provisioning/api.md +++ b/src/provisioning/api.md @@ -178,6 +178,69 @@ the bridge will be granted access. }] ``` +### GET /github/v1/account?userId={userId} + +Request the status of the users account. This will return a `loggedIn` value to determine if the +bridge has a GitHub identity stored for the user, and any organisations they have access to. + +### Response + +```json5 +{ + "loggedIn": true, + "organisations": { + "name": "half-shot", + "avatarUrl": "https://avatars.githubusercontent.com/u/8418310?v=4" + } +} +``` + +### GET /github/v1/orgs/{orgName}/repositories?userId={userId}&page={page}&perPage={perPage} + +Request a list of all repositories a user is a member of in the given org. The `owner` and `name` value of a repository can be given to create a new GitHub connection. + +This request is paginated, and `page` sets the page (defaults to `1`) while `perPage` (defaults to `10`) sets the number of entries per page. + +This request can be retried until the number of entries is less than the value of `perPage`. + +### Response + +```json5 +{ + "loggedIn": true, + "repositories": { + "name": "matrix-hookshot", + "owner": "half-shot", + "fullName": "half-shot/matrix-hookshot", + "avatarUrl": "https://avatars.githubusercontent.com/u/8418310?v=4", + "description": "A bridge between Matrix and multiple project management services, such as GitHub, GitLab and JIRA. " + } +} +``` + +### GET /github/v1/repositories?userId={userId}&page={page}&perPage={perPage} + +Request a list of all repositories a user is a member of (including those not belonging to an org). The `owner` and `name` value of a repository can be given to create a new GitHub connection. + +This request is paginated, and `page` sets the page (defaults to `1`) while `perPage` (defaults to `10`) sets the number of entries per page. + +This request can be retried until the number of entries is less than the value of `perPage`. + +### Response + +```json5 +{ + "loggedIn": true, + "repositories": { + "name": "matrix-hookshot", + "owner": "half-shot", + "fullName": "half-shot/matrix-hookshot", + "avatarUrl": "https://avatars.githubusercontent.com/u/8418310?v=4", + "description": "A bridge between Matrix and multiple project management services, such as GitHub, GitLab and JIRA. " + } +} +``` + ## JIRA @@ -228,4 +291,5 @@ a new JIRA connection. "name": "Jira Playground", "id": "10015" } -} \ No newline at end of file +} +``` \ No newline at end of file