github-issue-tower-defence-management 1.123.3 → 1.124.0

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.
Files changed (26) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/bin/adapter/entry-points/cli/projectConfig.js +6 -11
  3. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  4. package/bin/adapter/repositories/GraphqlProjectRepository.js +21 -36
  5. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  6. package/bin/adapter/repositories/githubGraphqlClient.js +99 -0
  7. package/bin/adapter/repositories/githubGraphqlClient.js.map +1 -0
  8. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +16 -33
  9. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  10. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +79 -142
  11. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/adapter/entry-points/cli/projectConfig.ts +6 -11
  14. package/src/adapter/repositories/GraphqlProjectRepository.ts +81 -77
  15. package/src/adapter/repositories/githubGraphqlClient.test.ts +251 -0
  16. package/src/adapter/repositories/githubGraphqlClient.ts +122 -0
  17. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +16 -33
  18. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +62 -0
  19. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +208 -237
  20. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  21. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  22. package/types/adapter/repositories/githubGraphqlClient.d.ts +21 -0
  23. package/types/adapter/repositories/githubGraphqlClient.d.ts.map +1 -0
  24. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  25. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +2 -0
  26. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
@@ -0,0 +1,122 @@
1
+ import ky from 'ky';
2
+
3
+ export const GITHUB_GRAPHQL_ENDPOINT = 'https://api.github.com/graphql';
4
+
5
+ export const RATE_LIMIT_SELECTION = 'rateLimit { cost remaining }';
6
+
7
+ export type GithubGraphqlRateLimit = {
8
+ cost: number;
9
+ remaining: number;
10
+ };
11
+
12
+ export const isMutationOperation = (query: string): boolean =>
13
+ query.trimStart().startsWith('mutation');
14
+
15
+ export const extractGraphqlOperationName = (query: string): string => {
16
+ const match = query.match(
17
+ /^\s*(?:query|mutation)\s+([A-Za-z_][A-Za-z0-9_]*)/,
18
+ );
19
+ return match ? match[1] : 'anonymous';
20
+ };
21
+
22
+ export const injectRateLimitSelection = (query: string): string => {
23
+ if (isMutationOperation(query)) {
24
+ return query;
25
+ }
26
+ const lastBraceIndex = query.lastIndexOf('}');
27
+ if (lastBraceIndex === -1) {
28
+ return query;
29
+ }
30
+ return `${query.slice(0, lastBraceIndex)} ${RATE_LIMIT_SELECTION}\n${query.slice(lastBraceIndex)}`;
31
+ };
32
+
33
+ const extractRateLimit = (
34
+ responseBody: unknown,
35
+ ): GithubGraphqlRateLimit | null => {
36
+ if (
37
+ typeof responseBody !== 'object' ||
38
+ responseBody === null ||
39
+ !('data' in responseBody)
40
+ ) {
41
+ return null;
42
+ }
43
+ const data: unknown = responseBody.data;
44
+ if (typeof data !== 'object' || data === null || !('rateLimit' in data)) {
45
+ return null;
46
+ }
47
+ const rateLimit: unknown = data.rateLimit;
48
+ if (
49
+ typeof rateLimit !== 'object' ||
50
+ rateLimit === null ||
51
+ !('cost' in rateLimit) ||
52
+ !('remaining' in rateLimit)
53
+ ) {
54
+ return null;
55
+ }
56
+ const { cost, remaining } = rateLimit;
57
+ if (typeof cost !== 'number' || typeof remaining !== 'number') {
58
+ return null;
59
+ }
60
+ return { cost, remaining };
61
+ };
62
+
63
+ export const logGithubGraphqlCost = (
64
+ query: string,
65
+ responseBody: unknown,
66
+ ): void => {
67
+ const rateLimit = extractRateLimit(responseBody);
68
+ if (!rateLimit) {
69
+ return;
70
+ }
71
+ console.log(
72
+ `githubGraphqlClient: query=${extractGraphqlOperationName(query)} cost=${rateLimit.cost} remaining=${rateLimit.remaining}`,
73
+ );
74
+ };
75
+
76
+ export const postGithubGraphqlJson = async <T>(params: {
77
+ ghToken: string;
78
+ query: string;
79
+ variables?: Record<string, unknown>;
80
+ }): Promise<T> => {
81
+ const response = await ky
82
+ .post(GITHUB_GRAPHQL_ENDPOINT, {
83
+ json: {
84
+ query: injectRateLimitSelection(params.query),
85
+ ...(params.variables !== undefined
86
+ ? { variables: params.variables }
87
+ : {}),
88
+ },
89
+ headers: {
90
+ Authorization: `Bearer ${params.ghToken}`,
91
+ },
92
+ })
93
+ .json<T>();
94
+ logGithubGraphqlCost(params.query, response);
95
+ return response;
96
+ };
97
+
98
+ export const fetchGithubGraphql = async (params: {
99
+ ghToken: string;
100
+ query: string;
101
+ variables?: Record<string, unknown>;
102
+ }): Promise<Response> => {
103
+ const response = await fetch(GITHUB_GRAPHQL_ENDPOINT, {
104
+ method: 'POST',
105
+ headers: {
106
+ Authorization: `Bearer ${params.ghToken}`,
107
+ 'Content-Type': 'application/json',
108
+ },
109
+ body: JSON.stringify({
110
+ query: injectRateLimitSelection(params.query),
111
+ variables: params.variables,
112
+ }),
113
+ });
114
+ if (response.ok) {
115
+ const responseBody: unknown = await response
116
+ .clone()
117
+ .json()
118
+ .catch((): null => null);
119
+ logGithubGraphqlCost(params.query, responseBody);
120
+ }
121
+ return response;
122
+ };
@@ -20,6 +20,7 @@ import {
20
20
  import { LocalStorageCacheRepository } from '../LocalStorageCacheRepository';
21
21
  import typia from 'typia';
22
22
  import { BaseGitHubRepository } from '../BaseGitHubRepository';
23
+ import { fetchGithubGraphql } from '../githubGraphqlClient';
23
24
  import { normalizeFieldName } from '../utils';
24
25
  import { LocalStorageRepository } from '../LocalStorageRepository';
25
26
  import { Member } from '../../../domain/entities/Member';
@@ -1160,7 +1161,7 @@ export class ApiV3CheerioRestIssueRepository
1160
1161
  mergeStateStatus: string | null;
1161
1162
  } | null> => {
1162
1163
  const query = `
1163
- query($owner: String!, $repo: String!, $prNumber: Int!) {
1164
+ query PullRequestMergeability($owner: String!, $repo: String!, $prNumber: Int!) {
1164
1165
  repository(owner: $owner, name: $repo) {
1165
1166
  pullRequest(number: $prNumber) {
1166
1167
  mergeable
@@ -1183,16 +1184,10 @@ export class ApiV3CheerioRestIssueRepository
1183
1184
  }
1184
1185
 
1185
1186
  const response = await this.fetchWithRateLimitRetry(() =>
1186
- fetch('https://api.github.com/graphql', {
1187
- method: 'POST',
1188
- headers: {
1189
- Authorization: `Bearer ${this.ghToken}`,
1190
- 'Content-Type': 'application/json',
1191
- },
1192
- body: JSON.stringify({
1193
- query,
1194
- variables: { owner, repo, prNumber },
1195
- }),
1187
+ fetchGithubGraphql({
1188
+ ghToken: this.ghToken,
1189
+ query,
1190
+ variables: { owner, repo, prNumber },
1196
1191
  }),
1197
1192
  );
1198
1193
 
@@ -1244,7 +1239,7 @@ export class ApiV3CheerioRestIssueRepository
1244
1239
  }
1245
1240
 
1246
1241
  const query = `
1247
- query($owner: String!, $repo: String!, $issueNumber: Int!, $after: String) {
1242
+ query IssueRelatedOpenPullRequests($owner: String!, $repo: String!, $issueNumber: Int!, $after: String) {
1248
1243
  repository(owner: $owner, name: $repo) {
1249
1244
  issue(number: $issueNumber) {
1250
1245
  timelineItems(first: 100, after: $after, itemTypes: [CROSS_REFERENCED_EVENT]) {
@@ -1348,16 +1343,10 @@ export class ApiV3CheerioRestIssueRepository
1348
1343
 
1349
1344
  while (hasNextPage) {
1350
1345
  const response = await this.fetchWithRateLimitRetry(() =>
1351
- fetch('https://api.github.com/graphql', {
1352
- method: 'POST',
1353
- headers: {
1354
- Authorization: `Bearer ${this.ghToken}`,
1355
- 'Content-Type': 'application/json',
1356
- },
1357
- body: JSON.stringify({
1358
- query,
1359
- variables: { owner, repo, issueNumber, after },
1360
- }),
1346
+ fetchGithubGraphql({
1347
+ ghToken: this.ghToken,
1348
+ query,
1349
+ variables: { owner, repo, issueNumber, after },
1361
1350
  }),
1362
1351
  );
1363
1352
 
@@ -1488,7 +1477,7 @@ export class ApiV3CheerioRestIssueRepository
1488
1477
  const { owner, repo, issueNumber: prNumber } = parsedUrl;
1489
1478
 
1490
1479
  const query = `
1491
- query($owner: String!, $repo: String!, $prNumber: Int!) {
1480
+ query PullRequestStatus($owner: String!, $repo: String!, $prNumber: Int!) {
1492
1481
  repository(owner: $owner, name: $repo) {
1493
1482
  pullRequest(number: $prNumber) {
1494
1483
  url
@@ -1565,16 +1554,10 @@ export class ApiV3CheerioRestIssueRepository
1565
1554
  `;
1566
1555
 
1567
1556
  const response = await this.fetchWithRateLimitRetry(() =>
1568
- fetch('https://api.github.com/graphql', {
1569
- method: 'POST',
1570
- headers: {
1571
- Authorization: `Bearer ${this.ghToken}`,
1572
- 'Content-Type': 'application/json',
1573
- },
1574
- body: JSON.stringify({
1575
- query,
1576
- variables: { owner, repo, prNumber },
1577
- }),
1557
+ fetchGithubGraphql({
1558
+ ghToken: this.ghToken,
1559
+ query,
1560
+ variables: { owner, repo, prNumber },
1578
1561
  }),
1579
1562
  );
1580
1563
 
@@ -22,6 +22,8 @@ import { HTTPError } from 'ky';
22
22
  import {
23
23
  GraphqlProjectItemRepository,
24
24
  PAGINATION_DELAY_MS,
25
+ PROJECT_ITEM_ASSIGNEES_FIRST,
26
+ PROJECT_ITEM_LABELS_FIRST,
25
27
  RATE_LIMIT_MAX_RETRIES,
26
28
  callWithRateLimitRetry,
27
29
  } from './GraphqlProjectItemRepository';
@@ -160,6 +162,66 @@ describe('GraphqlProjectItemRepository', () => {
160
162
  mockPost.mockReset();
161
163
  });
162
164
 
165
+ it('requests the reduced labels and assignees selection sizes', async () => {
166
+ const repository = new GraphqlProjectItemRepository(
167
+ new LocalStorageRepository(),
168
+ 'dummy-token',
169
+ );
170
+ mockPost.mockReturnValueOnce(makePageResponse(false, 'cursor-1', 1));
171
+
172
+ await repository.fetchProjectItems('test-project-id');
173
+
174
+ expect(PROJECT_ITEM_LABELS_FIRST).toBe(20);
175
+ expect(PROJECT_ITEM_ASSIGNEES_FIRST).toBe(10);
176
+ const sentQuery = extractRequestedQueryFromMockCall(
177
+ mockPost.mock.calls[0],
178
+ );
179
+ expect(sentQuery).toContain(
180
+ `labels(first: ${PROJECT_ITEM_LABELS_FIRST})`,
181
+ );
182
+ expect(sentQuery).toContain(
183
+ `assignees(first: ${PROJECT_ITEM_ASSIGNEES_FIRST})`,
184
+ );
185
+ expect(sentQuery).not.toContain('labels(first: 100)');
186
+ expect(sentQuery).not.toContain('assignees(first: 20)');
187
+ });
188
+
189
+ it('logs the rateLimit cost of the request in one line', async () => {
190
+ const repository = new GraphqlProjectItemRepository(
191
+ new LocalStorageRepository(),
192
+ 'dummy-token',
193
+ );
194
+ mockPost.mockReturnValueOnce(
195
+ mockJsonResponse({
196
+ data: {
197
+ node: {
198
+ items: {
199
+ totalCount: 0,
200
+ pageInfo: {
201
+ endCursor: 'cursor-1',
202
+ startCursor: 'cursor-start',
203
+ hasNextPage: false,
204
+ },
205
+ nodes: [],
206
+ },
207
+ },
208
+ rateLimit: { cost: 3, remaining: 4200 },
209
+ },
210
+ }),
211
+ );
212
+ const consoleLogSpy = jest
213
+ .spyOn(console, 'log')
214
+ .mockImplementation(() => {});
215
+ try {
216
+ await repository.fetchProjectItems('test-project-id');
217
+ expect(consoleLogSpy).toHaveBeenCalledWith(
218
+ 'githubGraphqlClient: query=GetProjectItems cost=3 remaining=4200',
219
+ );
220
+ } finally {
221
+ consoleLogSpy.mockRestore();
222
+ }
223
+ });
224
+
163
225
  it('should sleep between paginated requests to avoid 403', async () => {
164
226
  const localStorageRepository = new LocalStorageRepository();
165
227
  const repository = new GraphqlProjectItemRepository(