From eb8a02c37ff04fe3eb3e6e7f60eeef2ed47ee6f4 Mon Sep 17 00:00:00 2001
From: Sergei Zharinov <zharinov@users.noreply.github.com>
Date: Fri, 24 Feb 2023 12:01:58 +0300
Subject: [PATCH] refactor(github): Use schema validation for GraphQL (#20519)

---
 .../github/graphql/datasource-fetcher.spec.ts | 14 ++++--
 lib/util/github/graphql/datasource-fetcher.ts |  8 +++-
 lib/util/github/graphql/index.spec.ts         | 14 +++---
 .../releases-query-adapter.spec.ts            |  6 ++-
 .../query-adapters/releases-query-adapter.ts  | 36 ++++++++++++--
 .../query-adapters/tags-query-adapter.ts      | 47 +++++++++++++------
 lib/util/github/graphql/types.ts              | 42 ++---------------
 7 files changed, 96 insertions(+), 71 deletions(-)

diff --git a/lib/util/github/graphql/datasource-fetcher.spec.ts b/lib/util/github/graphql/datasource-fetcher.spec.ts
index 673a96c0ee..e948966477 100644
--- a/lib/util/github/graphql/datasource-fetcher.spec.ts
+++ b/lib/util/github/graphql/datasource-fetcher.spec.ts
@@ -44,11 +44,14 @@ const adapter: GithubGraphqlDatasourceAdapter<
     version,
     releaseTimestamp,
     foo,
-  }: TestAdapterInput): TestAdapterOutput => ({
-    version,
-    releaseTimestamp,
-    bar: foo,
-  }),
+  }: TestAdapterInput): TestAdapterOutput | null =>
+    version && releaseTimestamp && foo
+      ? {
+          version,
+          releaseTimestamp,
+          bar: foo,
+        }
+      : null,
 };
 
 function resp(
@@ -208,6 +211,7 @@ describe('util/github/graphql/datasource-fetcher', () => {
           resp(false, [
             { version: v3, releaseTimestamp: t3, foo: '3' },
             { version: v2, releaseTimestamp: t2, foo: '2' },
+            {} as never,
             { version: v1, releaseTimestamp: t1, foo: '1' },
           ])
         );
diff --git a/lib/util/github/graphql/datasource-fetcher.ts b/lib/util/github/graphql/datasource-fetcher.ts
index 6ab09ebb56..ca53fb6eff 100644
--- a/lib/util/github/graphql/datasource-fetcher.ts
+++ b/lib/util/github/graphql/datasource-fetcher.ts
@@ -239,8 +239,14 @@ export class GithubGraphqlDatasourceFetcher<
       const resultItems: ResultItem[] = [];
       for (const node of queryResult.nodes) {
         const item = this.datasourceAdapter.transform(node);
-        // istanbul ignore if: will be tested later
         if (!item) {
+          logger.once.info(
+            {
+              packageName: `${this.repoOwner}/${this.repoName}`,
+              baseUrl: this.baseUrl,
+            },
+            `GitHub GraphQL datasource: skipping empty item`
+          );
           continue;
         }
         resultItems.push(item);
diff --git a/lib/util/github/graphql/index.spec.ts b/lib/util/github/graphql/index.spec.ts
index 315ee6afc5..a234bac6dd 100644
--- a/lib/util/github/graphql/index.spec.ts
+++ b/lib/util/github/graphql/index.spec.ts
@@ -52,12 +52,14 @@ describe('util/github/graphql/index', () => {
             payload: {
               nodes: [
                 {
-                  id: 123,
-                  name: 'name',
-                  description: 'description',
                   version: '1.2.3',
                   releaseTimestamp: '2024-09-24',
+                  isDraft: false,
+                  isPrerelease: false,
                   url: 'https://example.com',
+                  id: 123,
+                  name: 'name',
+                  description: 'description',
                 },
               ],
             },
@@ -69,12 +71,12 @@ describe('util/github/graphql/index', () => {
 
     expect(res).toEqual([
       {
-        id: 123,
-        name: 'name',
-        description: 'description',
         version: '1.2.3',
         releaseTimestamp: '2024-09-24',
         url: 'https://example.com',
+        id: 123,
+        name: 'name',
+        description: 'description',
       },
     ]);
   });
diff --git a/lib/util/github/graphql/query-adapters/releases-query-adapter.spec.ts b/lib/util/github/graphql/query-adapters/releases-query-adapter.spec.ts
index 0bd88165e6..7e874c311e 100644
--- a/lib/util/github/graphql/query-adapters/releases-query-adapter.spec.ts
+++ b/lib/util/github/graphql/query-adapters/releases-query-adapter.spec.ts
@@ -1,5 +1,5 @@
-import type { GithubGraphqlRelease } from '../types';
 import { adapter } from './releases-query-adapter';
+import type { GithubGraphqlRelease } from './releases-query-adapter';
 
 const item: GithubGraphqlRelease = {
   version: '1.2.3',
@@ -28,6 +28,10 @@ describe('util/github/graphql/query-adapters/releases-query-adapter', () => {
     expect(adapter.transform({ ...item, isDraft: true })).toBeNull();
   });
 
+  it('handles invalid items', () => {
+    expect(adapter.transform({} as never)).toBeNull();
+  });
+
   it('marks prereleases as unstable', () => {
     expect(adapter.transform({ ...item, isPrerelease: true })).toMatchObject({
       isStable: false,
diff --git a/lib/util/github/graphql/query-adapters/releases-query-adapter.ts b/lib/util/github/graphql/query-adapters/releases-query-adapter.ts
index e149a0b25a..65cf0cfb10 100644
--- a/lib/util/github/graphql/query-adapters/releases-query-adapter.ts
+++ b/lib/util/github/graphql/query-adapters/releases-query-adapter.ts
@@ -1,6 +1,6 @@
+import { z } from 'zod';
 import type {
   GithubGraphqlDatasourceAdapter,
-  GithubGraphqlRelease,
   GithubReleaseItem,
 } from '../types';
 import { prepareQuery } from '../util';
@@ -30,7 +30,24 @@ const query = prepareQuery(`
   }
 `);
 
+const GithubGraphqlRelease = z.object({
+  version: z.string(),
+  releaseTimestamp: z.string(),
+  isDraft: z.boolean(),
+  isPrerelease: z.boolean(),
+  url: z.string(),
+  id: z.number().nullable(),
+  name: z.string().nullable(),
+  description: z.string().nullable(),
+});
+export type GithubGraphqlRelease = z.infer<typeof GithubGraphqlRelease>;
+
 function transform(item: GithubGraphqlRelease): GithubReleaseItem | null {
+  const releaseItem = GithubGraphqlRelease.safeParse(item);
+  if (!releaseItem.success) {
+    return null;
+  }
+
   const {
     version,
     releaseTimestamp,
@@ -40,7 +57,7 @@ function transform(item: GithubGraphqlRelease): GithubReleaseItem | null {
     id,
     name,
     description,
-  } = item;
+  } = releaseItem.data;
 
   if (isDraft) {
     return null;
@@ -50,11 +67,20 @@ function transform(item: GithubGraphqlRelease): GithubReleaseItem | null {
     version,
     releaseTimestamp,
     url,
-    id,
-    name,
-    description,
   };
 
+  if (id) {
+    result.id = id;
+  }
+
+  if (name) {
+    result.name = name;
+  }
+
+  if (description) {
+    result.description = description;
+  }
+
   if (isPrerelease) {
     result.isStable = false;
   }
diff --git a/lib/util/github/graphql/query-adapters/tags-query-adapter.ts b/lib/util/github/graphql/query-adapters/tags-query-adapter.ts
index 7d1e034bfa..0fbb2cf772 100644
--- a/lib/util/github/graphql/query-adapters/tags-query-adapter.ts
+++ b/lib/util/github/graphql/query-adapters/tags-query-adapter.ts
@@ -1,12 +1,30 @@
-import type {
-  GithubGraphqlDatasourceAdapter,
-  GithubGraphqlTag,
-  GithubTagItem,
-} from '../types';
+import { z } from 'zod';
+import type { GithubGraphqlDatasourceAdapter, GithubTagItem } from '../types';
 import { prepareQuery } from '../util';
 
 const key = 'github-tags-datasource-v2';
 
+const GithubGraphqlTag = z.object({
+  version: z.string(),
+  target: z.union([
+    z.object({
+      type: z.literal('Commit'),
+      oid: z.string(),
+      releaseTimestamp: z.string(),
+    }),
+    z.object({
+      type: z.literal('Tag'),
+      target: z.object({
+        oid: z.string(),
+      }),
+      tagger: z.object({
+        releaseTimestamp: z.string(),
+      }),
+    }),
+  ]),
+});
+export type GithubGraphqlTag = z.infer<typeof GithubGraphqlTag>;
+
 const query = prepareQuery(`
   refs(
     first: $count
@@ -41,16 +59,17 @@ const query = prepareQuery(`
   }`);
 
 function transform(item: GithubGraphqlTag): GithubTagItem | null {
-  const { version, target } = item;
-  if (target.type === 'Commit') {
-    const { oid: hash, releaseTimestamp } = target;
-    return { version, gitRef: version, hash, releaseTimestamp };
-  } else if (target.type === 'Tag') {
-    const { oid: hash } = target.target;
-    const { releaseTimestamp } = target.tagger;
-    return { version, gitRef: version, hash, releaseTimestamp };
+  const res = GithubGraphqlTag.safeParse(item);
+  if (!res.success) {
+    return null;
   }
-  return null;
+  const { version, target } = item;
+  const releaseTimestamp =
+    target.type === 'Commit'
+      ? target.releaseTimestamp
+      : target.tagger.releaseTimestamp;
+  const hash = target.type === 'Commit' ? target.oid : target.target.oid;
+  return { version, gitRef: version, hash, releaseTimestamp };
 }
 
 export const adapter: GithubGraphqlDatasourceAdapter<
diff --git a/lib/util/github/graphql/types.ts b/lib/util/github/graphql/types.ts
index 7b20caeed7..7bcfa4ec4a 100644
--- a/lib/util/github/graphql/types.ts
+++ b/lib/util/github/graphql/types.ts
@@ -58,51 +58,15 @@ export interface GithubPackageConfig {
   registryUrl?: string;
 }
 
-/**
- * GraphQL shape for releases
- */
-export interface GithubGraphqlRelease {
-  version: string;
-  releaseTimestamp: string;
-  isDraft: boolean;
-  isPrerelease: boolean;
-  url: string;
-  id: number;
-  name: string;
-  description: string;
-}
-
 /**
  * Result of GraphQL response transformation for releases (via adapter)
  */
 export interface GithubReleaseItem extends GithubDatasourceItem {
   isStable?: boolean;
   url: string;
-  id: number;
-  name: string;
-  description: string;
-}
-
-/**
- * GraphQL shape for tags
- */
-export interface GithubGraphqlTag {
-  version: string;
-  target:
-    | {
-        type: 'Commit';
-        oid: string;
-        releaseTimestamp: string;
-      }
-    | {
-        type: 'Tag';
-        target: {
-          oid: string;
-        };
-        tagger: {
-          releaseTimestamp: string;
-        };
-      };
+  id?: number;
+  name?: string;
+  description?: string;
 }
 
 /**
-- 
GitLab