Skip to content

Commit 691ccfb

Browse files
authored
feat: add getInstallationUrl method (#542)
1 parent f285e8b commit 691ccfb

File tree

5 files changed

+236
-0
lines changed

5 files changed

+236
-0
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- [`app.getInstallationOctokit`](#appgetinstallationoctokit)
1717
- [`app.eachInstallation`](#appeachinstallation)
1818
- [`app.eachRepository`](#appeachrepository)
19+
- [`app.getInstallationUrl`](#appgetinstallationurl)
1920
- [`app.webhooks`](#appwebhooks)
2021
- [`app.oauth`](#appoauth)
2122
- [Middlewares](#middlewares)
@@ -299,6 +300,22 @@ for await (const { octokit, repository } of app.eachRepository.iterator({ instal
299300
await app.eachRepository({ installationId }, ({ octokit, repository }) => /* ... */)
300301
```
301302

303+
### `app.getInstallationUrl`
304+
305+
```js
306+
const installationUrl = await app.getInstallationUrl();
307+
return res.redirect(installationUrl);
308+
```
309+
310+
Optionally pass the ID of a GitHub organization or user to request installation on that specific target.
311+
312+
If the user will be sent to a redirect URL after installation (such as if you request user authorization during installation), you can also supply a `state` string that will be included in the query of the post-install redirect.
313+
314+
```js
315+
const installationUrl = await app.getInstallationUrl({ state, target_id });
316+
return res.redirect(installationUrl);
317+
```
318+
302319
### `app.webhooks`
303320

304321
An [`@octokit/webhooks` instance](https://github.com/octokit/webhooks.js/#readme)

src/get-installation-url.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { App } from "./index.js";
2+
import type { GetInstallationUrlOptions } from "./types.js";
3+
4+
export function getInstallationUrlFactory(app: App) {
5+
let installationUrlBasePromise: Promise<string> | undefined;
6+
7+
return async function getInstallationUrl(
8+
options: GetInstallationUrlOptions = {},
9+
) {
10+
if (!installationUrlBasePromise) {
11+
installationUrlBasePromise = getInstallationUrlBase(app);
12+
}
13+
14+
const installationUrlBase = await installationUrlBasePromise;
15+
const installationUrl = new URL(installationUrlBase);
16+
17+
if (options.target_id !== undefined) {
18+
installationUrl.pathname += "/permissions";
19+
installationUrl.searchParams.append(
20+
"target_id",
21+
options.target_id.toFixed(),
22+
);
23+
}
24+
25+
if (options.state !== undefined) {
26+
installationUrl.searchParams.append("state", options.state);
27+
}
28+
29+
return installationUrl.href;
30+
};
31+
}
32+
33+
async function getInstallationUrlBase(app: App) {
34+
const { data: appInfo } = await app.octokit.request("GET /app");
35+
if (!appInfo) {
36+
throw new Error("[@octokit/app] unable to fetch metadata for app");
37+
}
38+
return `${appInfo.html_url}/installations/new`;
39+
}

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
EachInstallationInterface,
1010
EachRepositoryInterface,
1111
GetInstallationOctokitInterface,
12+
GetInstallationUrlInterface,
1213
} from "./types.js";
1314

1415
// Export types required for the App class
@@ -18,13 +19,15 @@ export type {
1819
EachInstallationInterface,
1920
EachRepositoryInterface,
2021
GetInstallationOctokitInterface,
22+
GetInstallationUrlInterface,
2123
} from "./types.js";
2224

2325
import { VERSION } from "./version.js";
2426
import { webhooks } from "./webhooks.js";
2527
import { eachInstallationFactory } from "./each-installation.js";
2628
import { eachRepositoryFactory } from "./each-repository.js";
2729
import { getInstallationOctokit } from "./get-installation-octokit.js";
30+
import { getInstallationUrlFactory } from "./get-installation-url.js";
2831

2932
type Constructor<T> = new (...args: any[]) => T;
3033

@@ -70,6 +73,7 @@ export class App<TOptions extends Options = Options> {
7073
>;
7174
eachInstallation: EachInstallationInterface<OctokitType<TOptions>>;
7275
eachRepository: EachRepositoryInterface<OctokitType<TOptions>>;
76+
getInstallationUrl: GetInstallationUrlInterface;
7377
log: {
7478
debug: (message: string, additionalInfo?: object) => void;
7579
info: (message: string, additionalInfo?: object) => void;
@@ -150,6 +154,7 @@ export class App<TOptions extends Options = Options> {
150154
this.eachRepository = eachRepositoryFactory(
151155
this,
152156
) as EachRepositoryInterface<OctokitType<TOptions>>;
157+
this.getInstallationUrl = getInstallationUrlFactory(this);
153158
}
154159
}
155160

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,12 @@ export interface EachRepositoryInterface<O> {
6767
export interface GetInstallationOctokitInterface<O> {
6868
(installationId: number): Promise<O>;
6969
}
70+
71+
export interface GetInstallationUrlOptions {
72+
state?: string;
73+
target_id?: number;
74+
}
75+
76+
export interface GetInstallationUrlInterface {
77+
(options?: GetInstallationUrlOptions): Promise<string>;
78+
}

test/get-installation-url.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { Octokit } from "@octokit/core";
2+
import fetchMock from "fetch-mock";
3+
import MockDate from "mockdate";
4+
5+
const APP_ID = 1;
6+
const PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
7+
MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
8+
lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
9+
p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
10+
rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
11+
ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
12+
gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
13+
K1r1/gycfDkUCQRP4DbZHt+458JlFHm8QL6VstKzkrp8mYDRhffY0WJnYJL98tr4
14+
4tohsDbqFGwmw2mIaHjl24LuWXyyP4xpAGDpl9IcusjXBxLQLp2m4AKXbWpzb0OL
15+
Ulrfc1ZooPck2uz7xlMIZOtLlOPjLz2DuejVe24JcwwHzrQWKOfA11R/9e50DVse
16+
hnSH/w46Q763y4I0E3BIoUMsolEKzh2ydAAyzkgabGQBUuamZotNfvJoDXeCi1LD
17+
8yNCWyTlYpJZJDDXooBU5EAsCvhN1sSRoaXWrlMSDB7r/E+aQyKua4KONqvmoJuC
18+
21vSKeECgYEA7yW6wBkVoNhgXnk8XSZv3W+Q0xtdVpidJeNGBWnczlZrummt4xw3
19+
xs6zV+rGUDy59yDkKwBKjMMa42Mni7T9Fx8+EKUuhVK3PVQyajoyQqFwT1GORJNz
20+
c/eYQ6VYOCSC8OyZmsBM2p+0D4FF2/abwSPMmy0NgyFLCUFVc3OECpkCgYEA5OAm
21+
I3wt5s+clg18qS7BKR2DuOFWrzNVcHYXhjx8vOSWV033Oy3yvdUBAhu9A1LUqpwy
22+
Ma+unIgxmvmUMQEdyHQMcgBsVs10dR/g2xGjMLcwj6kn+xr3JVIZnbRT50YuPhf+
23+
ns1ScdhP6upo9I0/sRsIuN96Gb65JJx94gQ4k9MCgYBO5V6gA2aMQvZAFLUicgzT
24+
u/vGea+oYv7tQfaW0J8E/6PYwwaX93Y7Q3QNXCoCzJX5fsNnoFf36mIThGHGiHY6
25+
y5bZPPWFDI3hUMa1Hu/35XS85kYOP6sGJjf4kTLyirEcNKJUWH7CXY+00cwvTkOC
26+
S4Iz64Aas8AilIhRZ1m3eQKBgQCUW1s9azQRxgeZGFrzC3R340LL530aCeta/6FW
27+
CQVOJ9nv84DLYohTVqvVowdNDTb+9Epw/JDxtDJ7Y0YU0cVtdxPOHcocJgdUGHrX
28+
ZcJjRIt8w8g/s4X6MhKasBYm9s3owALzCuJjGzUKcDHiO2DKu1xXAb0SzRcTzUCn
29+
7daCswKBgQDOYPZ2JGmhibqKjjLFm0qzpcQ6RPvPK1/7g0NInmjPMebP0K6eSPx0
30+
9/49J6WTD++EajN7FhktUSYxukdWaCocAQJTDNYP0K88G4rtC2IYy5JFn9SWz5oh
31+
x//0u+zd/R/QRUzLOw4N72/Hu+UG6MNt5iDZFCtapRaKt6OvSBwy8w==
32+
-----END RSA PRIVATE KEY-----`;
33+
const CLIENT_ID = "0123";
34+
const CLIENT_SECRET = "0123secret";
35+
const WEBHOOK_SECRET = "secret";
36+
37+
import { App } from "../src/index.ts";
38+
39+
describe("app.getInstallationUrl", () => {
40+
let app: InstanceType<typeof App>;
41+
let mock: typeof fetchMock;
42+
43+
beforeEach(() => {
44+
MockDate.set(0);
45+
mock = fetchMock.sandbox();
46+
47+
app = new App({
48+
appId: APP_ID,
49+
privateKey: PRIVATE_KEY,
50+
oauth: {
51+
clientId: CLIENT_ID,
52+
clientSecret: CLIENT_SECRET,
53+
},
54+
webhooks: {
55+
secret: WEBHOOK_SECRET,
56+
},
57+
Octokit: Octokit.defaults({
58+
request: {
59+
fetch: mock,
60+
},
61+
}),
62+
});
63+
});
64+
65+
test("throws when response is null", async () => {
66+
mock.getOnce("path:/app", {
67+
body: "null",
68+
headers: { "Content-Type": "application/json" },
69+
});
70+
71+
await expect(app.getInstallationUrl()).rejects.toThrow(
72+
"[@octokit/app] unable to fetch metadata for app",
73+
);
74+
75+
expect(mock.done()).toBe(true);
76+
});
77+
78+
test("returns correct url", async () => {
79+
mock.getOnce("path:/app", {
80+
html_url: "https://github.com/apps/octokit",
81+
});
82+
83+
const url = await app.getInstallationUrl();
84+
85+
expect(url).toEqual("https://github.com/apps/octokit/installations/new");
86+
expect(mock.done()).toBe(true);
87+
});
88+
89+
test("caches url", async () => {
90+
mock.getOnce("path:/app", {
91+
html_url: "https://github.com/apps/octokit",
92+
});
93+
94+
const urls = await Promise.all([
95+
app.getInstallationUrl(),
96+
app.getInstallationUrl(),
97+
app.getInstallationUrl(),
98+
]);
99+
100+
expect(urls).toEqual(
101+
new Array(3).fill("https://github.com/apps/octokit/installations/new"),
102+
);
103+
expect(mock.done()).toBe(true);
104+
});
105+
106+
test("does not cache state", async () => {
107+
mock.getOnce("path:/app", {
108+
html_url: "https://github.com/apps/octokit",
109+
});
110+
const state = "abc123";
111+
112+
const urlWithoutState = await app.getInstallationUrl();
113+
const urlWithState = await app.getInstallationUrl({ state });
114+
115+
expect(urlWithoutState).toEqual(
116+
"https://github.com/apps/octokit/installations/new",
117+
);
118+
expect(urlWithState).toEqual(
119+
`https://github.com/apps/octokit/installations/new?state=${state}`,
120+
);
121+
expect(mock.done()).toBe(true);
122+
});
123+
124+
test("adds the url-encoded state string to the url", async () => {
125+
mock.getOnce("path:/app", {
126+
html_url: "https://github.com/apps/octokit",
127+
});
128+
const state = "abc123%/{";
129+
130+
const url = await app.getInstallationUrl({ state });
131+
132+
expect(url).toEqual(
133+
`https://github.com/apps/octokit/installations/new?state=${encodeURIComponent(state)}`,
134+
);
135+
expect(mock.done()).toBe(true);
136+
});
137+
138+
test("appends /permissions to the url when target_id present", async () => {
139+
mock.getOnce("path:/app", {
140+
html_url: "https://github.com/apps/octokit",
141+
});
142+
const target_id = 456;
143+
144+
const url = await app.getInstallationUrl({ target_id });
145+
146+
expect(url).toEqual(
147+
`https://github.com/apps/octokit/installations/new/permissions?target_id=${target_id}`,
148+
);
149+
expect(mock.done()).toBe(true);
150+
});
151+
152+
test("adds both state and target_id to the url", async () => {
153+
mock.getOnce("path:/app", {
154+
html_url: "https://github.com/apps/octokit",
155+
});
156+
const state = "abc123";
157+
const target_id = 456;
158+
159+
const url = await app.getInstallationUrl({ state, target_id });
160+
161+
expect(url).toEqual(
162+
`https://github.com/apps/octokit/installations/new/permissions?target_id=${target_id}&state=${state}`,
163+
);
164+
expect(mock.done()).toBe(true);
165+
});
166+
});

0 commit comments

Comments
 (0)