github-issue-tower-defence-management 1.153.5 → 1.154.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 (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +187 -6
  3. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  4. package/bin/adapter/repositories/issue/RestIssueRepository.js +6 -0
  5. package/bin/adapter/repositories/issue/RestIssueRepository.js.map +1 -1
  6. package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js +10 -16
  7. package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js.map +1 -1
  8. package/bin/domain/usecases/IssueRejectionEvaluator.js +28 -11
  9. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  10. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +15 -3
  11. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +14 -0
  14. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +252 -0
  15. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +288 -6
  16. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +19 -0
  17. package/src/adapter/repositories/issue/RestIssueRepository.ts +13 -0
  18. package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.test.ts +211 -200
  19. package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.ts +13 -18
  20. package/src/domain/usecases/IssueRejectionEvaluator.ts +49 -15
  21. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +154 -0
  22. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +33 -5
  23. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +8 -0
  24. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +9 -2
  25. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  26. package/types/adapter/repositories/issue/RestIssueRepository.d.ts +1 -0
  27. package/types/adapter/repositories/issue/RestIssueRepository.d.ts.map +1 -1
  28. package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts +2 -2
  29. package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts.map +1 -1
  30. package/types/domain/usecases/IssueRejectionEvaluator.d.ts +6 -1
  31. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  32. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts +1 -1
  33. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
  34. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +3 -0
  35. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
@@ -108,6 +108,43 @@ type PrStatusComputationData = {
108
108
  }>;
109
109
  };
110
110
 
111
+ // One GraphQL query carrying this many aliased pull requests costs the same
112
+ // single rate-limit point as a query carrying one, measured against the live
113
+ // API by reading the rateLimit cost field it returns.
114
+ const SLIM_PULL_REQUEST_BATCH_SIZE = 100;
115
+ const SLIM_PULL_REQUEST_REVIEW_THREADS_PAGE_SIZE = 100;
116
+
117
+ type SlimPullRequestNodeResponse = {
118
+ url: string;
119
+ state: string;
120
+ isDraft?: boolean;
121
+ headRefName?: string;
122
+ baseRefName?: string;
123
+ mergeable?: string;
124
+ headRefOid?: string;
125
+ reviewThreads?: {
126
+ pageInfo: {
127
+ endCursor: string | null;
128
+ hasNextPage: boolean;
129
+ };
130
+ nodes: Array<{
131
+ isResolved: boolean;
132
+ }>;
133
+ };
134
+ };
135
+
136
+ type SlimPullRequestBatchResponse = {
137
+ data?: Record<
138
+ string,
139
+ { pullRequest?: SlimPullRequestNodeResponse | null } | null
140
+ > | null;
141
+ errors?: Array<{
142
+ message: string;
143
+ type?: string;
144
+ path?: Array<string | number>;
145
+ }>;
146
+ };
147
+
111
148
  type SlimPullRequestResponse = {
112
149
  data?: {
113
150
  repository?: {
@@ -206,6 +243,13 @@ function isSlimPullRequestResponse(
206
243
  return true;
207
244
  }
208
245
 
246
+ function isSlimPullRequestBatchResponse(
247
+ value: unknown,
248
+ ): value is SlimPullRequestBatchResponse {
249
+ if (typeof value !== 'object' || value === null) return false;
250
+ return true;
251
+ }
252
+
209
253
  function isBranchRulesResponse(
210
254
  value: unknown,
211
255
  ): value is BranchRulesResponseItem[] {
@@ -423,6 +467,7 @@ export class ApiV3CheerioRestIssueRepository
423
467
  RestIssueRepository,
424
468
  | 'createNewIssue'
425
469
  | 'updateIssue'
470
+ | 'updateIssueBody'
426
471
  | 'createComment'
427
472
  | 'getIssue'
428
473
  | 'updateLabels'
@@ -759,6 +804,12 @@ export class ApiV3CheerioRestIssueRepository
759
804
  updateIssue = async (issue: Issue): Promise<void> => {
760
805
  await this.restIssueRepository.updateIssue(issue);
761
806
  };
807
+ updateIssueBody = async (
808
+ issue: Pick<Issue, 'org' | 'repo' | 'number'>,
809
+ body: string,
810
+ ): Promise<void> => {
811
+ await this.restIssueRepository.updateIssueBody(issue, body);
812
+ };
762
813
  getIssueByUrl = async (url: string): Promise<Issue | null> => {
763
814
  const projectItem =
764
815
  await this.graphqlProjectItemRepository.fetchProjectItemByUrl(url);
@@ -1691,6 +1742,214 @@ export class ApiV3CheerioRestIssueRepository
1691
1742
  return this.buildRelatedPullRequestFromSlim(owner, repo, slimPullRequest);
1692
1743
  };
1693
1744
 
1745
+ // Resolves many pull requests with one GraphQL query per hundred instead of
1746
+ // one query per pull request. A URL this cannot settle is left out of the
1747
+ // returned map rather than mapped to null, so the caller falls back to
1748
+ // getOpenPullRequest for it and an unknown state is never mistaken for an
1749
+ // absent pull request.
1750
+ getOpenPullRequests = async (
1751
+ prUrls: string[],
1752
+ ): Promise<Map<string, RelatedPullRequest | null>> => {
1753
+ const resolved = new Map<string, RelatedPullRequest | null>();
1754
+ const parsedByUrl = new Map<
1755
+ string,
1756
+ { owner: string; repo: string; prNumber: number }
1757
+ >();
1758
+ for (const prUrl of Array.from(new Set(prUrls))) {
1759
+ const parsedUrl = this.parseIssueUrl(prUrl);
1760
+ if (!parsedUrl.isPr) {
1761
+ resolved.set(prUrl, null);
1762
+ continue;
1763
+ }
1764
+ parsedByUrl.set(prUrl, {
1765
+ owner: parsedUrl.owner,
1766
+ repo: parsedUrl.repo,
1767
+ prNumber: parsedUrl.issueNumber,
1768
+ });
1769
+ }
1770
+
1771
+ const urlsToFetch = Array.from(parsedByUrl.keys());
1772
+ for (
1773
+ let start = 0;
1774
+ start < urlsToFetch.length;
1775
+ start += SLIM_PULL_REQUEST_BATCH_SIZE
1776
+ ) {
1777
+ const batchUrls = urlsToFetch.slice(
1778
+ start,
1779
+ start + SLIM_PULL_REQUEST_BATCH_SIZE,
1780
+ );
1781
+ let slimByUrl: Map<string, SlimPullRequest | null>;
1782
+ try {
1783
+ slimByUrl = await this.fetchSlimPullRequestsInOneQuery(
1784
+ batchUrls.map((prUrl) => ({
1785
+ prUrl,
1786
+ ...this.requireParsedPullRequest(parsedByUrl, prUrl),
1787
+ })),
1788
+ );
1789
+ } catch (error) {
1790
+ console.warn(
1791
+ `ApiV3CheerioRestIssueRepository: batched pull request status query failed, leaving ${batchUrls.length} pull request(s) to per-request resolution. error: ${error instanceof Error ? error.message : String(error)}`,
1792
+ );
1793
+ continue;
1794
+ }
1795
+ for (const prUrl of batchUrls) {
1796
+ if (!slimByUrl.has(prUrl)) {
1797
+ continue;
1798
+ }
1799
+ const slimPullRequest = slimByUrl.get(prUrl) ?? null;
1800
+ if (!slimPullRequest || slimPullRequest.state !== 'OPEN') {
1801
+ resolved.set(prUrl, null);
1802
+ continue;
1803
+ }
1804
+ const parsed = this.requireParsedPullRequest(parsedByUrl, prUrl);
1805
+ try {
1806
+ resolved.set(
1807
+ prUrl,
1808
+ await this.buildRelatedPullRequestFromSlim(
1809
+ parsed.owner,
1810
+ parsed.repo,
1811
+ slimPullRequest,
1812
+ ),
1813
+ );
1814
+ } catch (error) {
1815
+ console.warn(
1816
+ `ApiV3CheerioRestIssueRepository: building the pull request status failed, leaving it to per-request resolution. prUrl: ${prUrl} error: ${error instanceof Error ? error.message : String(error)}`,
1817
+ );
1818
+ }
1819
+ }
1820
+ }
1821
+ return resolved;
1822
+ };
1823
+
1824
+ private requireParsedPullRequest = (
1825
+ parsedByUrl: Map<string, { owner: string; repo: string; prNumber: number }>,
1826
+ prUrl: string,
1827
+ ): { owner: string; repo: string; prNumber: number } => {
1828
+ const parsed = parsedByUrl.get(prUrl);
1829
+ if (!parsed) {
1830
+ throw new Error(`Pull request URL was not parsed: ${prUrl}`);
1831
+ }
1832
+ return parsed;
1833
+ };
1834
+
1835
+ // A pull request whose review threads do not fit the first page is omitted
1836
+ // from the returned map, so the caller resolves it through the paginating
1837
+ // single-pull-request path and the resolved-thread state stays complete.
1838
+ private fetchSlimPullRequestsInOneQuery = async (
1839
+ references: {
1840
+ prUrl: string;
1841
+ owner: string;
1842
+ repo: string;
1843
+ prNumber: number;
1844
+ }[],
1845
+ ): Promise<Map<string, SlimPullRequest | null>> => {
1846
+ const aliasOf = (index: number): string => `pullRequest${index}`;
1847
+ const variableDeclarations = references
1848
+ .map(
1849
+ (_, index) =>
1850
+ `$owner${index}: String!, $repo${index}: String!, $prNumber${index}: Int!`,
1851
+ )
1852
+ .join(', ');
1853
+ const selections = references
1854
+ .map(
1855
+ (
1856
+ _,
1857
+ index,
1858
+ ) => ` ${aliasOf(index)}: repository(owner: $owner${index}, name: $repo${index}) {
1859
+ pullRequest(number: $prNumber${index}) {
1860
+ url
1861
+ state
1862
+ isDraft
1863
+ headRefName
1864
+ baseRefName
1865
+ mergeable
1866
+ headRefOid
1867
+ reviewThreads(first: ${SLIM_PULL_REQUEST_REVIEW_THREADS_PAGE_SIZE}) {
1868
+ pageInfo {
1869
+ endCursor
1870
+ hasNextPage
1871
+ }
1872
+ nodes {
1873
+ isResolved
1874
+ }
1875
+ }
1876
+ }
1877
+ }`,
1878
+ )
1879
+ .join('\n');
1880
+ const query = `query PullRequestSlimStatusBatch(${variableDeclarations}) {\n${selections}\n}`;
1881
+ const variables: Record<string, string | number> = {};
1882
+ references.forEach((reference, index) => {
1883
+ variables[`owner${index}`] = reference.owner;
1884
+ variables[`repo${index}`] = reference.repo;
1885
+ variables[`prNumber${index}`] = reference.prNumber;
1886
+ });
1887
+
1888
+ const response = await this.fetchWithRateLimitRetry(() =>
1889
+ fetchGithubGraphql({ ghToken: this.ghToken, query, variables }),
1890
+ );
1891
+ if (!response.ok) {
1892
+ throw new Error(
1893
+ `Failed to fetch pull requests from GitHub GraphQL API: HTTP ${response.status}`,
1894
+ );
1895
+ }
1896
+ const responseData: unknown = await response.json();
1897
+ if (!isSlimPullRequestBatchResponse(responseData)) {
1898
+ throw new Error('Unexpected response shape when fetching pull requests');
1899
+ }
1900
+ const errors = responseData.errors || [];
1901
+ const notFoundAliases = new Set(
1902
+ errors
1903
+ .filter((error) => error.type === 'NOT_FOUND')
1904
+ .map((error) => String(error.path?.[0] ?? '')),
1905
+ );
1906
+ const otherErrorAliases = new Set(
1907
+ errors
1908
+ .filter((error) => error.type !== 'NOT_FOUND')
1909
+ .map((error) => String(error.path?.[0] ?? '')),
1910
+ );
1911
+ if (!responseData.data) {
1912
+ throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`);
1913
+ }
1914
+ const unattributedError = errors.some(
1915
+ (error) => error.type !== 'NOT_FOUND' && !error.path,
1916
+ );
1917
+ if (unattributedError) {
1918
+ throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`);
1919
+ }
1920
+
1921
+ const slimByUrl = new Map<string, SlimPullRequest | null>();
1922
+ references.forEach((reference, index) => {
1923
+ const alias = aliasOf(index);
1924
+ if (otherErrorAliases.has(alias)) {
1925
+ return;
1926
+ }
1927
+ const node = responseData.data?.[alias]?.pullRequest ?? null;
1928
+ if (!node) {
1929
+ if (notFoundAliases.has(alias) || !errors.length) {
1930
+ slimByUrl.set(reference.prUrl, null);
1931
+ }
1932
+ return;
1933
+ }
1934
+ if (node.reviewThreads?.pageInfo.hasNextPage === true) {
1935
+ return;
1936
+ }
1937
+ slimByUrl.set(reference.prUrl, {
1938
+ url: node.url,
1939
+ state: node.state,
1940
+ isDraft: node.isDraft,
1941
+ headRefName: node.headRefName,
1942
+ baseRefName: node.baseRefName,
1943
+ mergeable: node.mergeable,
1944
+ headRefOid: node.headRefOid,
1945
+ reviewThreads: (node.reviewThreads?.nodes || []).map((thread) => ({
1946
+ isResolved: thread.isResolved,
1947
+ })),
1948
+ });
1949
+ });
1950
+ return slimByUrl;
1951
+ };
1952
+
1694
1953
  closePullRequest = async (prUrl: string): Promise<void> => {
1695
1954
  const { owner, repo, issueNumber: prNumber } = this.parseIssueUrl(prUrl);
1696
1955
  const response = await this.fetchWithRateLimitRetry(() =>
@@ -2044,9 +2303,9 @@ export class ApiV3CheerioRestIssueRepository
2044
2303
  await this.restIssueRepository.createComment(issueOrPrUrl, commentBody);
2045
2304
  };
2046
2305
 
2047
- getIssueOrPullRequestBody = async (url: string): Promise<string> => {
2306
+ private fetchIssueBodyResponse = (url: string): Promise<Response> => {
2048
2307
  const { owner, repo, issueNumber } = this.parseIssueUrl(url);
2049
- const response = await this.fetchWithRateLimitRetry(() =>
2308
+ return this.fetchWithRateLimitRetry(() =>
2050
2309
  fetch(
2051
2310
  `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`,
2052
2311
  {
@@ -2058,10 +2317,12 @@ export class ApiV3CheerioRestIssueRepository
2058
2317
  },
2059
2318
  ),
2060
2319
  );
2061
- if (!response.ok) {
2062
- const reason = await this.formatGitHubErrorWithStatus(response);
2063
- throw new Error(`Failed to fetch body for ${url}: ${reason}`);
2064
- }
2320
+ };
2321
+
2322
+ private parseIssueBodyResponse = async (
2323
+ url: string,
2324
+ response: Response,
2325
+ ): Promise<string> => {
2065
2326
  const body: unknown = await response.json();
2066
2327
  if (!isIssueOrPullRequestBodyResponse(body)) {
2067
2328
  throw new Error(
@@ -2071,6 +2332,27 @@ export class ApiV3CheerioRestIssueRepository
2071
2332
  return body.body ?? '';
2072
2333
  };
2073
2334
 
2335
+ getIssueOrPullRequestBody = async (url: string): Promise<string> => {
2336
+ const response = await this.fetchIssueBodyResponse(url);
2337
+ if (!response.ok) {
2338
+ const reason = await this.formatGitHubErrorWithStatus(response);
2339
+ throw new Error(`Failed to fetch body for ${url}: ${reason}`);
2340
+ }
2341
+ return this.parseIssueBodyResponse(url, response);
2342
+ };
2343
+
2344
+ getIssueBodyByUrl = async (url: string): Promise<string | null> => {
2345
+ const response = await this.fetchIssueBodyResponse(url);
2346
+ if (response.status === 404) {
2347
+ return null;
2348
+ }
2349
+ if (!response.ok) {
2350
+ const reason = await this.formatGitHubErrorWithStatus(response);
2351
+ throw new Error(`Failed to fetch body for ${url}: ${reason}`);
2352
+ }
2353
+ return this.parseIssueBodyResponse(url, response);
2354
+ };
2355
+
2074
2356
  getIssueOrPullRequestComments = async (
2075
2357
  url: string,
2076
2358
  ): Promise<IssueComment[]> => {
@@ -263,6 +263,25 @@ describe('RestIssueRepository', () => {
263
263
  });
264
264
  });
265
265
 
266
+ describe('updateIssueBody', () => {
267
+ it('sends only the body so the title, labels, assignees and state are left untouched', async () => {
268
+ const issue = buildIssue();
269
+
270
+ mockPatch.mockResolvedValue(undefined);
271
+
272
+ await restIssueRepository.updateIssueBody(issue, 'rewritten body');
273
+
274
+ expect(mockPatch).toHaveBeenCalledTimes(1);
275
+ expect(mockPatch).toHaveBeenCalledWith(
276
+ 'https://api.github.com/repos/HiromiShikata/test-repository/issues/40',
277
+ {
278
+ json: { body: 'rewritten body' },
279
+ headers: { Authorization: 'token dummy-token' },
280
+ },
281
+ );
282
+ });
283
+ });
284
+
266
285
  describe('searchIssues', () => {
267
286
  const buildSearchItem = (
268
287
  overrides: Partial<{
@@ -105,6 +105,19 @@ export class RestIssueRepository
105
105
  );
106
106
  };
107
107
 
108
+ updateIssueBody = async (
109
+ issue: Pick<Issue, 'org' | 'repo' | 'number'>,
110
+ body: string,
111
+ ): Promise<void> => {
112
+ await ky.patch(
113
+ `https://api.github.com/repos/${issue.org}/${issue.repo}/issues/${issue.number}`,
114
+ {
115
+ json: { body },
116
+ headers: { Authorization: `token ${this.ghToken}` },
117
+ },
118
+ );
119
+ };
120
+
108
121
  updateLabels = async (
109
122
  issue: Issue,
110
123
  labels: Issue['labels'],