From 0cc759c66ea32f9b7aa0910f5fcdf5ffe581ca97 Mon Sep 17 00:00:00 2001
From: Sergei Zharinov <zharinov@users.noreply.github.com>
Date: Sun, 10 Mar 2024 08:54:57 -0300
Subject: [PATCH] feat(github): Use schema for issue objects (#27782)

---
 lib/modules/platform/github/graphql.ts    |   1 +
 lib/modules/platform/github/index.spec.ts | 110 ++++++++++++++++++++--
 lib/modules/platform/github/index.ts      |  52 +++++-----
 lib/modules/platform/github/issue.ts      |  28 ++++++
 lib/modules/platform/github/types.ts      |  10 +-
 5 files changed, 160 insertions(+), 41 deletions(-)
 create mode 100644 lib/modules/platform/github/issue.ts

diff --git a/lib/modules/platform/github/graphql.ts b/lib/modules/platform/github/graphql.ts
index 9e5f0155a2..2f0890af74 100644
--- a/lib/modules/platform/github/graphql.ts
+++ b/lib/modules/platform/github/graphql.ts
@@ -48,6 +48,7 @@ query(
         state
         title
         body
+        updatedAt
       }
     }
   }
diff --git a/lib/modules/platform/github/index.spec.ts b/lib/modules/platform/github/index.spec.ts
index d887628eb9..d0a33bb4ea 100644
--- a/lib/modules/platform/github/index.spec.ts
+++ b/lib/modules/platform/github/index.spec.ts
@@ -1632,11 +1632,15 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'open',
                     title: 'title-2',
+                    body: 'body-2',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                   {
                     number: 1,
                     state: 'open',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                 ],
               },
@@ -1665,11 +1669,15 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'open',
                     title: 'title-2',
+                    body: 'body-2',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                   {
                     number: 1,
                     state: 'open',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                 ],
               },
@@ -1677,9 +1685,21 @@ describe('modules/platform/github/index', () => {
           },
         })
         .get('/repos/undefined/issues/2')
-        .reply(200, { body: 'new-content' });
+        .reply(200, {
+          number: 2,
+          state: 'open',
+          title: 'title-2',
+          body: 'new-content',
+          updated_at: '2023-01-01T00:00:00Z',
+        });
       const res = await github.findIssue('title-2');
-      expect(res).not.toBeNull();
+      expect(res).toEqual({
+        number: 2,
+        state: 'open',
+        title: 'title-2',
+        body: 'new-content',
+        lastModified: '2023-01-01T00:00:00Z',
+      });
     });
   });
 
@@ -1704,11 +1724,15 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'open',
                     title: 'title-2',
+                    body: 'body-2',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                   {
                     number: 1,
                     state: 'open',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                 ],
               },
@@ -1744,11 +1768,15 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'open',
                     title: 'title-2',
+                    body: 'body-2',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                   {
                     number: 1,
                     state: 'closed',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                 ],
               },
@@ -1782,11 +1810,15 @@ describe('modules/platform/github/index', () => {
                   number: 2,
                   state: 'open',
                   title: 'title-2',
+                  body: 'body-2',
+                  updatedAt: '2022-01-01T00:00:00Z',
                 },
                 {
                   number: 1,
                   state: 'closed',
                   title: 'title-1',
+                  body: 'body-1',
+                  updatedAt: '2021-01-01T00:00:00Z',
                 },
               ],
             },
@@ -1852,16 +1884,22 @@ describe('modules/platform/github/index', () => {
                     number: 3,
                     state: 'open',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                   {
                     number: 2,
                     state: 'open',
                     title: 'title-2',
+                    body: 'body-2',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                   {
                     number: 1,
                     state: 'closed',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                 ],
               },
@@ -1899,11 +1937,15 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'open',
                     title: 'title-2',
+                    body: 'body-2',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                   {
                     number: 1,
                     state: 'open',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                 ],
               },
@@ -1911,7 +1953,13 @@ describe('modules/platform/github/index', () => {
           },
         })
         .get('/repos/some/repo/issues/2')
-        .reply(200, { body: 'new-content' })
+        .reply(200, {
+          number: 2,
+          state: 'open',
+          title: 'title-2',
+          body: 'new-content',
+          updated_at: '2023-01-01T00:00:00Z',
+        })
         .patch('/repos/some/repo/issues/2')
         .reply(200);
       const res = await github.ensureIssue({
@@ -1942,11 +1990,15 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'open',
                     title: 'title-2',
+                    body: 'body-2',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                   {
                     number: 1,
                     state: 'open',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                 ],
               },
@@ -1954,7 +2006,13 @@ describe('modules/platform/github/index', () => {
           },
         })
         .get('/repos/some/repo/issues/2')
-        .reply(200, { body: 'new-content' })
+        .reply(200, {
+          number: 2,
+          state: 'open',
+          title: 'title-2',
+          body: 'new-content',
+          updated_at: '2023-01-01T00:00:00Z',
+        })
         .patch('/repos/some/repo/issues/2')
         .reply(200);
       const res = await github.ensureIssue({
@@ -1986,11 +2044,15 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'open',
                     title: 'title-2',
+                    body: 'newer-content',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                   {
                     number: 1,
                     state: 'open',
                     title: 'title-1',
+                    body: 'new-content',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                 ],
               },
@@ -2026,21 +2088,31 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'open',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                   {
                     number: 1,
                     state: 'open',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                 ],
               },
             },
           },
         })
-        .patch('/repos/some/repo/issues/1')
-        .reply(200)
         .get('/repos/some/repo/issues/2')
-        .reply(200, { body: 'newer-content' });
+        .reply(200, {
+          number: 2,
+          state: 'open',
+          title: 'title-1',
+          body: 'newer-content',
+          updated_at: '2021-01-01T00:00:00Z',
+        })
+        .patch('/repos/some/repo/issues/1')
+        .reply(200);
       const res = await github.ensureIssue({
         title: 'title-1',
         body: 'newer-content',
@@ -2068,6 +2140,8 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'close',
                     title: 'title-2',
+                    body: 'body-2',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                 ],
               },
@@ -2075,7 +2149,13 @@ describe('modules/platform/github/index', () => {
           },
         })
         .get('/repos/some/repo/issues/2')
-        .reply(200, { body: 'new-content' })
+        .reply(200, {
+          number: 2,
+          state: 'closed',
+          title: 'title-2',
+          body: 'new-content',
+          updated_at: '2023-01-01T00:00:00Z',
+        })
         .post('/repos/some/repo/issues')
         .reply(200);
       const res = await github.ensureIssue({
@@ -2107,6 +2187,8 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'open',
                     title: 'title-2',
+                    body: 'body-2',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                 ],
               },
@@ -2114,7 +2196,13 @@ describe('modules/platform/github/index', () => {
           },
         })
         .get('/repos/some/repo/issues/2')
-        .reply(200, { body: 'new-content' });
+        .reply(200, {
+          number: 2,
+          state: 'open',
+          title: 'title-2',
+          body: 'new-content',
+          updated_at: '2023-01-01T00:00:00Z',
+        });
       const res = await github.ensureIssue({
         title: 'title-2',
         body: 'new-content',
@@ -2144,11 +2232,15 @@ describe('modules/platform/github/index', () => {
                     number: 2,
                     state: 'open',
                     title: 'title-2',
+                    body: 'body-2',
+                    updatedAt: '2022-01-01T00:00:00Z',
                   },
                   {
                     number: 1,
                     state: 'open',
                     title: 'title-1',
+                    body: 'body-1',
+                    updatedAt: '2021-01-01T00:00:00Z',
                   },
                 ],
               },
diff --git a/lib/modules/platform/github/index.ts b/lib/modules/platform/github/index.ts
index b5c6948fe4..e629dd6e6e 100644
--- a/lib/modules/platform/github/index.ts
+++ b/lib/modules/platform/github/index.ts
@@ -56,7 +56,6 @@ import type {
   EnsureIssueConfig,
   EnsureIssueResult,
   FindPRConfig,
-  Issue,
   MergePRConfig,
   PlatformParams,
   PlatformPrOptions,
@@ -75,6 +74,7 @@ import {
   repoInfoQuery,
   vulnerabilityAlertsQuery,
 } from './graphql';
+import { GithubIssue as Issue } from './issue';
 import { massageMarkdownLinks } from './massage-markdown-links';
 import { getPrCache, updatePrCache } from './pr';
 import type {
@@ -1197,9 +1197,8 @@ export async function setBranchStatus({
 
 // Issue
 
-/* istanbul ignore next */
 async function getIssues(): Promise<Issue[]> {
-  const result = await githubApi.queryRepoField<Issue>(
+  const result = await githubApi.queryRepoField<unknown>(
     getIssuesQuery,
     'issues',
     {
@@ -1212,10 +1211,7 @@ async function getIssues(): Promise<Issue[]> {
   );
 
   logger.debug(`Retrieved ${result.length} issues`);
-  return result.map((issue) => ({
-    ...issue,
-    state: issue.state?.toLowerCase(),
-  }));
+  return Issue.array().parse(result);
 }
 
 export async function getIssueList(): Promise<Issue[]> {
@@ -1239,16 +1235,13 @@ export async function getIssue(
     return null;
   }
   try {
-    const issueBody = (
-      await githubApi.getJson<{ body: string }>(
-        `repos/${config.parentRepo ?? config.repository}/issues/${number}`,
-        { memCache: useCache, repoCache: true },
-      )
-    ).body.body;
-    return {
-      number,
-      body: issueBody,
-    };
+    const repo = config.parentRepo ?? config.repository;
+    const { body: issue } = await githubApi.getJson(
+      `repos/${repo}/issues/${number}`,
+      { memCache: useCache, repoCache: true },
+      Issue,
+    );
+    return issue;
   } catch (err) /* istanbul ignore next */ {
     logger.debug({ err, number }, 'Error getting issue');
     return null;
@@ -1264,8 +1257,7 @@ export async function findIssue(title: string): Promise<Issue | null> {
     return null;
   }
   logger.debug(`Found issue ${issue.number}`);
-  // TODO: can number be required? (#22198)
-  return getIssue(issue.number!);
+  return getIssue(issue.number);
 }
 
 async function closeIssue(issueNumber: number): Promise<void> {
@@ -1319,18 +1311,17 @@ export async function ensureIssue({
       for (const i of issues) {
         if (i.state === 'open' && i.number !== issue.number) {
           logger.warn({ issueNo: i.number }, 'Closing duplicate issue');
-          // TODO #22198
-          await closeIssue(i.number!);
+          await closeIssue(i.number);
         }
       }
-      const issueBody = (
-        await githubApi.getJson<{ body: string }>(
-          `repos/${config.parentRepo ?? config.repository}/issues/${
-            issue.number
-          }`,
-          { repoCache: true },
-        )
-      ).body.body;
+      const repo = config.parentRepo ?? config.repository;
+      const {
+        body: { body: issueBody },
+      } = await githubApi.getJson(
+        `repos/${repo}/issues/${issue.number}`,
+        { repoCache: true },
+        Issue,
+      );
       if (
         issue.title === title &&
         issueBody === body &&
@@ -1393,8 +1384,7 @@ export async function ensureIssueClosing(title: string): Promise<void> {
   const issueList = await getIssueList();
   for (const issue of issueList) {
     if (issue.state === 'open' && issue.title === title) {
-      // TODO #22198
-      await closeIssue(issue.number!);
+      await closeIssue(issue.number);
       logger.debug(`Issue closed, issueNo: ${issue.number}`);
     }
   }
diff --git a/lib/modules/platform/github/issue.ts b/lib/modules/platform/github/issue.ts
new file mode 100644
index 0000000000..295b8c11e9
--- /dev/null
+++ b/lib/modules/platform/github/issue.ts
@@ -0,0 +1,28 @@
+import { z } from 'zod';
+import type { GithubIssue as Issue } from './types';
+
+const GithubIssueBase = z.object({
+  number: z.number(),
+  state: z.string().transform((val) => val.toLowerCase()),
+  title: z.string(),
+  body: z.string(),
+});
+
+const GithubGraphqlIssue = GithubIssueBase.extend({
+  updatedAt: z.string(),
+}).transform((issue): Issue => {
+  const lastModified = issue.updatedAt;
+  const { number, state, title, body } = issue;
+  return { number, state, title, body, lastModified };
+});
+
+const GithubRestIssue = GithubIssueBase.extend({
+  updated_at: z.string(),
+}).transform((issue): Issue => {
+  const lastModified = issue.updated_at;
+  const { number, state, title, body } = issue;
+  return { number, state, title, body, lastModified };
+});
+
+export const GithubIssue = z.union([GithubGraphqlIssue, GithubRestIssue]);
+export type GithubIssue = z.infer<typeof GithubIssue>;
diff --git a/lib/modules/platform/github/types.ts b/lib/modules/platform/github/types.ts
index 48c41ef007..986154717f 100644
--- a/lib/modules/platform/github/types.ts
+++ b/lib/modules/platform/github/types.ts
@@ -97,7 +97,7 @@ export interface LocalRepoConfig {
   forkToken?: string;
   forkCreation?: boolean;
   prList: GhPr[] | null;
-  issueList: any[] | null;
+  issueList: GithubIssue[] | null;
   mergeMethod: 'rebase' | 'squash' | 'merge';
   defaultBranch: string;
   repositoryOwner: string;
@@ -153,3 +153,11 @@ export interface ApiPageCache<T extends ApiPageItem = ApiPageItem> {
   items: Record<number, T>;
   lastModified?: string;
 }
+
+export interface GithubIssue {
+  body: string;
+  number: number;
+  state: string;
+  title: string;
+  lastModified: string;
+}
-- 
GitLab