@flink-app/github-app-plugin 0.12.1-alpha.39 → 0.12.1-alpha.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -417,10 +417,63 @@ const issue = await client.createIssue("facebook", "react", {
417
417
  body: "Found a bug...",
418
418
  });
419
419
 
420
+ // Remove repository from installation
421
+ await client.removeRepository(987654321);
422
+
420
423
  // Generic API call
421
424
  const response = await client.request("GET", "/rate_limit");
422
425
  ```
423
426
 
427
+ ### Removing Repositories from Installation
428
+
429
+ You can remove a repository from a GitHub App installation to revoke access to that specific repository while keeping the installation active for other repositories:
430
+
431
+ ```typescript
432
+ const client = await ctx.plugins.githubApp.getClient(installationId);
433
+
434
+ // Remove repository by ID (numeric ID, not name)
435
+ await client.removeRepository(987654321);
436
+ ```
437
+
438
+ **Important Notes:**
439
+ - Use the numeric repository ID, not the repository name or full_name
440
+ - This only removes the repository from the installation; it does not delete the repository itself
441
+ - The installation remains active for other repositories
442
+ - If the repository was already removed manually, the API will return a 404 error
443
+ - Requires appropriate installation permissions
444
+
445
+ **Example: Repository Disconnect Handler**
446
+
447
+ ```typescript
448
+ // In a Flink handler (e.g., DeleteRepository.ts)
449
+ const DeleteRepository: Handler = async ({ ctx, req }) => {
450
+ const { id } = req.params;
451
+
452
+ // Fetch repository from database
453
+ const repository = await ctx.repos.repositoryRepo.getById(id);
454
+ if (!repository) {
455
+ return notFound(`Repository with id ${id} not found`);
456
+ }
457
+
458
+ // Remove repository from GitHub App installation
459
+ try {
460
+ const client = await ctx.plugins.githubApp.getClient(
461
+ repository.githubInstallationId
462
+ );
463
+ await client.removeRepository(repository.githubRepoId);
464
+ } catch (error) {
465
+ console.error('Failed to remove repository from GitHub:', error);
466
+ // Continue with local cleanup even if GitHub API call fails
467
+ // (repository may have already been removed manually)
468
+ }
469
+
470
+ // Update database status
471
+ await ctx.repos.repositoryRepo.updateStatus(id, "disconnected");
472
+
473
+ return { data: { success: true } };
474
+ };
475
+ ```
476
+
424
477
  ## Authentication Integration
425
478
 
426
479
  This plugin is **auth-agnostic** and works with any authentication system. You implement your own installation callback handler with your own auth logic.
@@ -125,6 +125,27 @@ export declare class GitHubAPIClient {
125
125
  * @returns Created issue
126
126
  */
127
127
  createIssue(owner: string, repo: string, params: CreateIssueParams): Promise<Issue>;
128
+ /**
129
+ * Remove a repository from this installation
130
+ *
131
+ * Revokes the GitHub App's access to a specific repository while keeping
132
+ * the installation active for other repositories.
133
+ *
134
+ * Note: This requires the installation to have been granted access to the
135
+ * repository. If the app was installed at the account level with access
136
+ * to all repositories, this method can be used to selectively revoke
137
+ * access to specific repositories.
138
+ *
139
+ * @param repositoryId - GitHub repository ID (numeric ID, not full_name)
140
+ * @throws Error if repository not found or access cannot be revoked
141
+ *
142
+ * @example
143
+ * ```typescript
144
+ * const client = await ctx.plugins.githubApp.getClient(12345);
145
+ * await client.removeRepository(987654321);
146
+ * ```
147
+ */
148
+ removeRepository(repositoryId: number): Promise<void>;
128
149
  /**
129
150
  * Sleep utility for retry delays
130
151
  *
@@ -76,6 +76,10 @@ class GitHubAPIClient {
76
76
  error.message = errorBody;
77
77
  throw error;
78
78
  }
79
+ // Handle 204 No Content (void response)
80
+ if (response.status === 204) {
81
+ return undefined;
82
+ }
79
83
  // Parse and return response
80
84
  const responseData = await response.json();
81
85
  return responseData;
@@ -132,6 +136,29 @@ class GitHubAPIClient {
132
136
  async createIssue(owner, repo, params) {
133
137
  return this.request("POST", `/repos/${owner}/${repo}/issues`, params);
134
138
  }
139
+ /**
140
+ * Remove a repository from this installation
141
+ *
142
+ * Revokes the GitHub App's access to a specific repository while keeping
143
+ * the installation active for other repositories.
144
+ *
145
+ * Note: This requires the installation to have been granted access to the
146
+ * repository. If the app was installed at the account level with access
147
+ * to all repositories, this method can be used to selectively revoke
148
+ * access to specific repositories.
149
+ *
150
+ * @param repositoryId - GitHub repository ID (numeric ID, not full_name)
151
+ * @throws Error if repository not found or access cannot be revoked
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * const client = await ctx.plugins.githubApp.getClient(12345);
156
+ * await client.removeRepository(987654321);
157
+ * ```
158
+ */
159
+ async removeRepository(repositoryId) {
160
+ await this.request("DELETE", `/user/installations/${this.installationId}/repositories/${repositoryId}`);
161
+ }
135
162
  /**
136
163
  * Sleep utility for retry delays
137
164
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/github-app-plugin",
3
- "version": "0.12.1-alpha.39",
3
+ "version": "0.12.1-alpha.42",
4
4
  "description": "Flink plugin for GitHub App integration with installation management and webhook handling",
5
5
  "scripts": {
6
6
  "test": "node --preserve-symlinks -r ts-node/register -- node_modules/jasmine/bin/jasmine --config=./spec/support/jasmine.json",
@@ -23,8 +23,8 @@
23
23
  "jsonwebtoken": "^9.0.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@flink-app/flink": "^0.12.1-alpha.35",
27
- "@flink-app/test-utils": "^0.12.1-alpha.38",
26
+ "@flink-app/flink": "^0.12.1-alpha.40",
27
+ "@flink-app/test-utils": "^0.12.1-alpha.40",
28
28
  "@types/jasmine": "^3.7.1",
29
29
  "@types/jsonwebtoken": "^9.0.5",
30
30
  "@types/node": "22.13.10",
@@ -37,5 +37,5 @@
37
37
  "tsc-watch": "^4.2.9",
38
38
  "typescript": "5.4.5"
39
39
  },
40
- "gitHead": "5be6cf2e80d665d08d380bc5e495bf9e20ff7b6e"
40
+ "gitHead": "e7a3e35c85ee9cd1eaa9629664460a1936f0698a"
41
41
  }
@@ -52,6 +52,172 @@ describe("Services", () => {
52
52
  expect(apiClient).toBeTruthy();
53
53
  });
54
54
 
55
+ describe("removeRepository", () => {
56
+ it("should construct correct DELETE request", () => {
57
+ const appId = "123456";
58
+ const installationId = 12345;
59
+ const repositoryId = 987654321;
60
+ const authService = new GitHubAuthService(appId, testPrivateKeyBase64, "https://api.github.com");
61
+ const apiClient = new GitHubAPIClient(installationId, authService);
62
+
63
+ // Test that the method exists and has the correct signature
64
+ expect(typeof apiClient.removeRepository).toBe("function");
65
+ expect(apiClient.removeRepository.length).toBe(1);
66
+ });
67
+
68
+ // Note: Testing actual API calls with mocked responses
69
+ it("should handle successful removal (204 No Content)", async () => {
70
+ const appId = "123456";
71
+ const installationId = 12345;
72
+ const repositoryId = 987654321;
73
+ const authService = new GitHubAuthService(appId, testPrivateKeyBase64, "https://api.github.com");
74
+ const apiClient = new GitHubAPIClient(installationId, authService);
75
+
76
+ // Mock successful installation token retrieval
77
+ spyOn(authService, "getInstallationToken").and.returnValue(Promise.resolve("test-token"));
78
+
79
+ // Mock fetch to return 204 No Content
80
+ const originalFetch = global.fetch;
81
+ global.fetch = jasmine.createSpy("fetch").and.returnValue(
82
+ Promise.resolve({
83
+ ok: true,
84
+ status: 204,
85
+ headers: new Headers(),
86
+ } as Response)
87
+ );
88
+
89
+ try {
90
+ await apiClient.removeRepository(repositoryId);
91
+ expect(global.fetch).toHaveBeenCalledWith(
92
+ `https://api.github.com/user/installations/${installationId}/repositories/${repositoryId}`,
93
+ jasmine.objectContaining({
94
+ method: "DELETE",
95
+ headers: jasmine.objectContaining({
96
+ Authorization: "Bearer test-token",
97
+ }),
98
+ })
99
+ );
100
+ } finally {
101
+ global.fetch = originalFetch;
102
+ }
103
+ });
104
+
105
+ it("should handle repository not found error (404)", async () => {
106
+ const appId = "123456";
107
+ const installationId = 12345;
108
+ const repositoryId = 999999999;
109
+ const authService = new GitHubAuthService(appId, testPrivateKeyBase64, "https://api.github.com");
110
+ const apiClient = new GitHubAPIClient(installationId, authService);
111
+
112
+ // Mock installation token retrieval
113
+ spyOn(authService, "getInstallationToken").and.returnValue(Promise.resolve("test-token"));
114
+
115
+ // Mock fetch to return 404
116
+ const originalFetch = global.fetch;
117
+ global.fetch = jasmine.createSpy("fetch").and.returnValue(
118
+ Promise.resolve({
119
+ ok: false,
120
+ status: 404,
121
+ statusText: "Not Found",
122
+ text: () => Promise.resolve(JSON.stringify({ message: "Repository not found" })),
123
+ headers: new Headers(),
124
+ } as any)
125
+ );
126
+
127
+ try {
128
+ let errorThrown = false;
129
+ try {
130
+ await apiClient.removeRepository(repositoryId);
131
+ } catch (error) {
132
+ errorThrown = true;
133
+ }
134
+ expect(errorThrown).toBe(true);
135
+ } finally {
136
+ global.fetch = originalFetch;
137
+ }
138
+ });
139
+
140
+ it("should handle forbidden error (403)", async () => {
141
+ const appId = "123456";
142
+ const installationId = 12345;
143
+ const repositoryId = 987654321;
144
+ const authService = new GitHubAuthService(appId, testPrivateKeyBase64, "https://api.github.com");
145
+ const apiClient = new GitHubAPIClient(installationId, authService);
146
+
147
+ // Mock installation token retrieval
148
+ spyOn(authService, "getInstallationToken").and.returnValue(Promise.resolve("test-token"));
149
+
150
+ let callCount = 0;
151
+ // Mock fetch to return 403 and track retries
152
+ const originalFetch = global.fetch;
153
+ global.fetch = jasmine.createSpy("fetch").and.callFake(() => {
154
+ callCount++;
155
+ return Promise.resolve({
156
+ ok: false,
157
+ status: 403,
158
+ statusText: "Forbidden",
159
+ text: () => Promise.resolve(JSON.stringify({ message: "Insufficient permissions" })),
160
+ headers: new Headers({ "x-ratelimit-remaining": "100" }),
161
+ } as any);
162
+ });
163
+
164
+ try {
165
+ let errorThrown = false;
166
+ try {
167
+ await apiClient.removeRepository(repositoryId);
168
+ } catch (error) {
169
+ errorThrown = true;
170
+ }
171
+ expect(errorThrown).toBe(true);
172
+ // Should have retried due to 403 status
173
+ expect(callCount).toBeGreaterThan(1);
174
+ } finally {
175
+ global.fetch = originalFetch;
176
+ }
177
+ }, 10000); // Increase timeout since it will retry
178
+
179
+ it("should retry on rate limit (403 with rate limit)", async () => {
180
+ const appId = "123456";
181
+ const installationId = 12345;
182
+ const repositoryId = 987654321;
183
+ const authService = new GitHubAuthService(appId, testPrivateKeyBase64, "https://api.github.com");
184
+ const apiClient = new GitHubAPIClient(installationId, authService);
185
+
186
+ // Mock installation token retrieval
187
+ spyOn(authService, "getInstallationToken").and.returnValue(Promise.resolve("test-token"));
188
+
189
+ let callCount = 0;
190
+ const originalFetch = global.fetch;
191
+ global.fetch = jasmine.createSpy("fetch").and.callFake(() => {
192
+ callCount++;
193
+ if (callCount === 1) {
194
+ // First call: rate limited
195
+ return Promise.resolve({
196
+ ok: false,
197
+ status: 403,
198
+ statusText: "Forbidden",
199
+ text: () => Promise.resolve(JSON.stringify({ message: "Rate limit exceeded" })),
200
+ headers: new Headers({ "x-ratelimit-remaining": "0" }),
201
+ } as Response);
202
+ } else {
203
+ // Second call: success
204
+ return Promise.resolve({
205
+ ok: true,
206
+ status: 204,
207
+ headers: new Headers(),
208
+ } as Response);
209
+ }
210
+ });
211
+
212
+ try {
213
+ await apiClient.removeRepository(repositoryId);
214
+ expect(callCount).toBe(2); // Should have retried
215
+ } finally {
216
+ global.fetch = originalFetch;
217
+ }
218
+ }, 10000); // Increase timeout for retry delays
219
+ });
220
+
55
221
  // Note: Testing actual API calls requires mocking GitHub API
56
222
  // These are tested in integration tests instead
57
223
  });
@@ -145,6 +145,11 @@ export class GitHubAPIClient {
145
145
  throw error;
146
146
  }
147
147
 
148
+ // Handle 204 No Content (void response)
149
+ if (response.status === 204) {
150
+ return undefined as T;
151
+ }
152
+
148
153
  // Parse and return response
149
154
  const responseData = await response.json();
150
155
  return responseData as T;
@@ -206,6 +211,30 @@ export class GitHubAPIClient {
206
211
  return this.request<Issue>("POST", `/repos/${owner}/${repo}/issues`, params);
207
212
  }
208
213
 
214
+ /**
215
+ * Remove a repository from this installation
216
+ *
217
+ * Revokes the GitHub App's access to a specific repository while keeping
218
+ * the installation active for other repositories.
219
+ *
220
+ * Note: This requires the installation to have been granted access to the
221
+ * repository. If the app was installed at the account level with access
222
+ * to all repositories, this method can be used to selectively revoke
223
+ * access to specific repositories.
224
+ *
225
+ * @param repositoryId - GitHub repository ID (numeric ID, not full_name)
226
+ * @throws Error if repository not found or access cannot be revoked
227
+ *
228
+ * @example
229
+ * ```typescript
230
+ * const client = await ctx.plugins.githubApp.getClient(12345);
231
+ * await client.removeRepository(987654321);
232
+ * ```
233
+ */
234
+ async removeRepository(repositoryId: number): Promise<void> {
235
+ await this.request<void>("DELETE", `/user/installations/${this.installationId}/repositories/${repositoryId}`);
236
+ }
237
+
209
238
  /**
210
239
  * Sleep utility for retry delays
211
240
  *