From 4f141dfa84706ad4c91d214a5228619c511f1ad5 Mon Sep 17 00:00:00 2001
From: ReubenFrankel <60552974+ReubenFrankel@users.noreply.github.com>
Date: Mon, 25 Mar 2024 12:10:04 +0000
Subject: [PATCH] [GithubLastCommit] [GitlabLastCommit] [GiteaLastCommit]
 Support file path for last commit (#10041)

* Support file path for GitHub last commit

* Support file path for GitLab last commit

* Support file path for Gitea last commit

* Define common `relativeUri` validator

* Sort imports

* Add more tests for path variations

* Fix test name

Co-authored-by: chris48s <chris48s@users.noreply.github.com>

* Update Gitea 404 message

* Handle case when no commits are returned for GitHub and GitLab

---------

Co-authored-by: chris48s <chris48s@users.noreply.github.com>
---
 services/gitea/gitea-helper.js                |  2 +-
 services/gitea/gitea-last-commit.service.js   | 25 +++++++--
 services/gitea/gitea-last-commit.tester.js    | 53 ++++++++++++++++++-
 services/github/github-last-commit.service.js | 33 +++++++++---
 services/github/github-last-commit.tester.js  | 24 +++++++++
 services/gitlab/gitlab-last-commit.service.js | 32 +++++++----
 services/gitlab/gitlab-last-commit.tester.js  | 39 +++++++++++++-
 services/validators.js                        |  7 +++
 8 files changed, 187 insertions(+), 28 deletions(-)

diff --git a/services/gitea/gitea-helper.js b/services/gitea/gitea-helper.js
index 56bba95207..c3c74e3d84 100644
--- a/services/gitea/gitea-helper.js
+++ b/services/gitea/gitea-helper.js
@@ -8,7 +8,7 @@ To specify another instance like [codeberg](https://codeberg.org/), [forgejo](ht
 function httpErrorsFor() {
   return {
     403: 'private repo',
-    404: 'user or repo not found',
+    404: 'user, repo or path not found',
   }
 }
 
diff --git a/services/gitea/gitea-last-commit.service.js b/services/gitea/gitea-last-commit.service.js
index cb40bb48be..bf9c3c28df 100644
--- a/services/gitea/gitea-last-commit.service.js
+++ b/services/gitea/gitea-last-commit.service.js
@@ -1,8 +1,8 @@
 import Joi from 'joi'
+import { age as ageColor } from '../color-formatters.js'
 import { pathParam, queryParam } from '../index.js'
-import { optionalUrl } from '../validators.js'
 import { formatDate } from '../text-formatters.js'
-import { age as ageColor } from '../color-formatters.js'
+import { optionalUrl, relativeUri } from '../validators.js'
 import GiteaBase from './gitea-base.js'
 import { description, httpErrorsFor } from './gitea-helper.js'
 
@@ -25,10 +25,11 @@ const schema = Joi.array()
 const displayEnum = ['author', 'committer']
 
 const queryParamSchema = Joi.object({
-  gitea_url: optionalUrl,
+  path: relativeUri,
   display_timestamp: Joi.string()
     .valid(...displayEnum)
     .default('author'),
+  gitea_url: optionalUrl,
 }).required()
 
 export default class GiteaLastCommit extends GiteaBase {
@@ -54,6 +55,12 @@ export default class GiteaLastCommit extends GiteaBase {
             name: 'repo',
             example: 'tea',
           }),
+          queryParam({
+            name: 'path',
+            example: 'README.md',
+            schema: { type: 'string' },
+            description: 'File path to resolve the last commit for.',
+          }),
           queryParam({
             name: 'display_timestamp',
             example: 'committer',
@@ -84,6 +91,12 @@ export default class GiteaLastCommit extends GiteaBase {
             name: 'branch',
             example: 'main',
           }),
+          queryParam({
+            name: 'path',
+            example: 'README.md',
+            schema: { type: 'string' },
+            description: 'File path to resolve the last commit for.',
+          }),
           queryParam({
             name: 'display_timestamp',
             example: 'committer',
@@ -108,12 +121,12 @@ export default class GiteaLastCommit extends GiteaBase {
     }
   }
 
-  async fetch({ user, repo, branch, baseUrl }) {
+  async fetch({ user, repo, branch, baseUrl, path }) {
     // https://gitea.com/api/swagger#/repository
     return super.fetch({
       schema,
       url: `${baseUrl}/api/v1/repos/${user}/${repo}/commits`,
-      options: { searchParams: { sha: branch } },
+      options: { searchParams: { sha: branch, path } },
       httpErrors: httpErrorsFor(),
     })
   }
@@ -123,6 +136,7 @@ export default class GiteaLastCommit extends GiteaBase {
     {
       gitea_url: baseUrl = 'https://gitea.com',
       display_timestamp: displayTimestamp,
+      path,
     },
   ) {
     const body = await this.fetch({
@@ -130,6 +144,7 @@ export default class GiteaLastCommit extends GiteaBase {
       repo,
       branch,
       baseUrl,
+      path,
     })
     return this.constructor.render({
       commitDate: body[0].commit[displayTimestamp].date,
diff --git a/services/gitea/gitea-last-commit.tester.js b/services/gitea/gitea-last-commit.tester.js
index bc1e133cbf..f77ac6af27 100644
--- a/services/gitea/gitea-last-commit.tester.js
+++ b/services/gitea/gitea-last-commit.tester.js
@@ -8,6 +8,41 @@ t.create('Last Commit (recent)').get('/gitea/tea.json').expectBadge({
   message: isFormattedDate,
 })
 
+t.create('Last Commit (recent) (top-level file path)')
+  .get('/gitea/tea.json?path=README.md')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
+t.create('Last Commit (recent) (top-level dir path)')
+  .get('/gitea/tea.json?path=docs')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
+t.create('Last Commit (recent) (top-level dir path with trailing slash)')
+  .get('/gitea/tea.json?path=docs/')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
+t.create('Last Commit (recent) (nested dir path)')
+  .get('/gitea/tea.json?path=docs/CLI.md')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
+t.create('Last Commit (recent) (path)')
+  .get('/gitea/tea.json?path=README.md')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
 t.create('Last Commit (recent) (self-managed)')
   .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
   .expectBadge({
@@ -24,9 +59,23 @@ t.create('Last Commit (on-branch) (self-managed)')
     message: isFormattedDate,
   })
 
-t.create('Last Commit (project not found)')
+t.create('Last Commit (user not found)')
   .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
   .expectBadge({
     label: 'last commit',
-    message: 'user or repo not found',
+    message: 'user, repo or path not found',
+  })
+
+t.create('Last Commit (repo not found)')
+  .get('/gitea/not-a-repo.json')
+  .expectBadge({
+    label: 'last commit',
+    message: 'user, repo or path not found',
+  })
+
+t.create('Last Commit (path not found)')
+  .get('/gitea/tea.json?path=not/a/dir')
+  .expectBadge({
+    label: 'last commit',
+    message: 'user, repo or path not found',
   })
diff --git a/services/github/github-last-commit.service.js b/services/github/github-last-commit.service.js
index 58759c13b1..b0fb5d4fc4 100644
--- a/services/github/github-last-commit.service.js
+++ b/services/github/github-last-commit.service.js
@@ -1,7 +1,8 @@
 import Joi from 'joi'
-import { pathParam, queryParam } from '../index.js'
-import { formatDate } from '../text-formatters.js'
 import { age as ageColor } from '../color-formatters.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { formatDate } from '../text-formatters.js'
+import { relativeUri } from '../validators.js'
 import { GithubAuthV3Service } from './github-auth-service.js'
 import { documentation, httpErrorsFor } from './github-helpers.js'
 
@@ -16,14 +17,14 @@ const schema = Joi.array()
           date: Joi.string().required(),
         }).required(),
       }).required(),
-    }).required(),
+    }),
   )
   .required()
-  .min(1)
 
 const displayEnum = ['author', 'committer']
 
 const queryParamSchema = Joi.object({
+  path: relativeUri,
   display_timestamp: Joi.string()
     .valid(...displayEnum)
     .default('author'),
@@ -45,6 +46,12 @@ export default class GithubLastCommit extends GithubAuthV3Service {
         parameters: [
           pathParam({ name: 'user', example: 'google' }),
           pathParam({ name: 'repo', example: 'skia' }),
+          queryParam({
+            name: 'path',
+            example: 'README.md',
+            schema: { type: 'string' },
+            description: 'File path to resolve the last commit for.',
+          }),
           queryParam({
             name: 'display_timestamp',
             example: 'committer',
@@ -62,6 +69,12 @@ export default class GithubLastCommit extends GithubAuthV3Service {
           pathParam({ name: 'user', example: 'google' }),
           pathParam({ name: 'repo', example: 'skia' }),
           pathParam({ name: 'branch', example: 'infra/config' }),
+          queryParam({
+            name: 'path',
+            example: 'README.md',
+            schema: { type: 'string' },
+            description: 'File path to resolve the last commit for.',
+          }),
           queryParam({
             name: 'display_timestamp',
             example: 'committer',
@@ -82,20 +95,24 @@ export default class GithubLastCommit extends GithubAuthV3Service {
     }
   }
 
-  async fetch({ user, repo, branch }) {
+  async fetch({ user, repo, branch, path }) {
     return this._requestJson({
       url: `/repos/${user}/${repo}/commits`,
-      options: { searchParams: { sha: branch } },
+      options: { searchParams: { sha: branch, path } },
       schema,
       httpErrors: httpErrorsFor(),
     })
   }
 
   async handle({ user, repo, branch }, queryParams) {
-    const body = await this.fetch({ user, repo, branch })
+    const { path, display_timestamp: displayTimestamp } = queryParams
+    const body = await this.fetch({ user, repo, branch, path })
+    const [commit] = body.map(obj => obj.commit)
+
+    if (!commit) throw new NotFound({ prettyMessage: 'no commits found' })
 
     return this.constructor.render({
-      commitDate: body[0].commit[queryParams.display_timestamp].date,
+      commitDate: commit[displayTimestamp].date,
     })
   }
 }
diff --git a/services/github/github-last-commit.tester.js b/services/github/github-last-commit.tester.js
index f1399dc3d1..fe82cc7425 100644
--- a/services/github/github-last-commit.tester.js
+++ b/services/github/github-last-commit.tester.js
@@ -14,6 +14,26 @@ t.create('last commit (on branch)')
   .get('/badges/badgr.co/shielded.json')
   .expectBadge({ label: 'last commit', message: 'july 2013' })
 
+t.create('last commit (by top-level file path)')
+  .get('/badges/badgr.co.json?path=README.md')
+  .expectBadge({ label: 'last commit', message: 'september 2013' })
+
+t.create('last commit (by top-level dir path)')
+  .get('/badges/badgr.co.json?path=badgr')
+  .expectBadge({ label: 'last commit', message: 'june 2013' })
+
+t.create('last commit (by top-level dir path with trailing slash)')
+  .get('/badges/badgr.co.json?path=badgr/')
+  .expectBadge({ label: 'last commit', message: 'june 2013' })
+
+t.create('last commit (by nested file path)')
+  .get('/badges/badgr.co.json?path=badgr/colors.py')
+  .expectBadge({ label: 'last commit', message: 'june 2013' })
+
+t.create('last commit (on branch) (by top-level file path)')
+  .get('/badges/badgr.co/shielded.json?path=README.md')
+  .expectBadge({ label: 'last commit', message: 'june 2013' })
+
 t.create('last commit (by committer)')
   .get('/badges/badgr.co/shielded.json?display_timestamp=committer')
   .expectBadge({ label: 'last commit', message: 'july 2013' })
@@ -21,3 +41,7 @@ t.create('last commit (by committer)')
 t.create('last commit (repo not found)')
   .get('/badges/helmets.json')
   .expectBadge({ label: 'last commit', message: 'repo not found' })
+
+t.create('last commit (no commits found)')
+  .get('/badges/badgr.co/shielded.json?path=not/a/dir')
+  .expectBadge({ label: 'last commit', message: 'no commits found' })
diff --git a/services/gitlab/gitlab-last-commit.service.js b/services/gitlab/gitlab-last-commit.service.js
index c998883056..af17127296 100644
--- a/services/gitlab/gitlab-last-commit.service.js
+++ b/services/gitlab/gitlab-last-commit.service.js
@@ -1,23 +1,23 @@
 import Joi from 'joi'
-import { pathParam, queryParam } from '../index.js'
-import { optionalUrl } from '../validators.js'
-import { formatDate } from '../text-formatters.js'
 import { age as ageColor } from '../color-formatters.js'
-import { description, httpErrorsFor } from './gitlab-helper.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { formatDate } from '../text-formatters.js'
+import { optionalUrl, relativeUri } from '../validators.js'
 import GitLabBase from './gitlab-base.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
 
 const schema = Joi.array()
   .items(
     Joi.object({
       committed_date: Joi.string().required(),
-    }).required(),
+    }),
   )
   .required()
-  .min(1)
 
 const queryParamSchema = Joi.object({
   ref: Joi.string(),
   gitlab_url: optionalUrl,
+  path: relativeUri,
 }).required()
 
 const refText = `
@@ -53,6 +53,12 @@ export default class GitlabLastCommit extends GitLabBase {
             name: 'ref',
             example: 'master',
           }),
+          queryParam({
+            name: 'path',
+            example: 'README.md',
+            schema: { type: 'string' },
+            description: 'File path to resolve the last commit for.',
+          }),
         ],
       },
     },
@@ -67,13 +73,13 @@ export default class GitlabLastCommit extends GitLabBase {
     }
   }
 
-  async fetch({ project, baseUrl, ref }) {
+  async fetch({ project, baseUrl, ref, path }) {
     // https://docs.gitlab.com/ee/api/commits.html#list-repository-commits
     return super.fetch({
       url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
         project,
       )}/repository/commits`,
-      options: { searchParams: { ref_name: ref } },
+      options: { searchParams: { ref_name: ref, path } },
       schema,
       httpErrors: httpErrorsFor('project not found'),
     })
@@ -81,9 +87,13 @@ export default class GitlabLastCommit extends GitLabBase {
 
   async handle(
     { project },
-    { gitlab_url: baseUrl = 'https://gitlab.com', ref },
+    { gitlab_url: baseUrl = 'https://gitlab.com', ref, path },
   ) {
-    const data = await this.fetch({ project, baseUrl, ref })
-    return this.constructor.render({ commitDate: data[0].committed_date })
+    const data = await this.fetch({ project, baseUrl, ref, path })
+    const [commit] = data
+
+    if (!commit) throw new NotFound({ prettyMessage: 'no commits found' })
+
+    return this.constructor.render({ commitDate: commit.committed_date })
   }
 }
diff --git a/services/gitlab/gitlab-last-commit.tester.js b/services/gitlab/gitlab-last-commit.tester.js
index ecc29fdbd7..08ab428468 100644
--- a/services/gitlab/gitlab-last-commit.tester.js
+++ b/services/gitlab/gitlab-last-commit.tester.js
@@ -8,13 +8,43 @@ t.create('last commit (recent)').get('/gitlab-org/gitlab.json').expectBadge({
   message: isFormattedDate,
 })
 
-t.create('last commit (on ref and ancient)')
+t.create('last commit (on ref) (ancient)')
   .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee')
   .expectBadge({
     label: 'last commit',
     message: 'march 2021',
   })
 
+t.create('last commit (on ref) (ancient) (by top-level file path)')
+  .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=README.md')
+  .expectBadge({
+    label: 'last commit',
+    message: 'december 2020',
+  })
+
+t.create('last commit (on ref) (ancient) (by top-level dir path)')
+  .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=changelogs')
+  .expectBadge({
+    label: 'last commit',
+    message: 'march 2021',
+  })
+
+t.create(
+  'last commit (on ref) (ancient) (by top-level dir path with trailing slash)',
+)
+  .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=changelogs/')
+  .expectBadge({
+    label: 'last commit',
+    message: 'march 2021',
+  })
+
+t.create('last commit (on ref) (ancient) (by nested file path)')
+  .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=changelogs/README.md')
+  .expectBadge({
+    label: 'last commit',
+    message: 'september 2020',
+  })
+
 t.create('last commit (self-managed)')
   .get('/gitlab-cn/gitlab.json?gitlab_url=https://jihulab.com')
   .expectBadge({
@@ -28,3 +58,10 @@ t.create('last commit (project not found)')
     label: 'last commit',
     message: 'project not found',
   })
+
+t.create('last commit (no commits found)')
+  .get('/gitlab-org/gitlab.json?path=not/a/dir')
+  .expectBadge({
+    label: 'last commit',
+    message: 'no commits found',
+  })
diff --git a/services/validators.js b/services/validators.js
index df8d72a4d4..b445527866 100644
--- a/services/validators.js
+++ b/services/validators.js
@@ -79,3 +79,10 @@ export const optionalUrl = Joi.string().uri({ scheme: ['http', 'https'] })
 export const fileSize = Joi.string()
   .regex(/^[0-9]+(b|kb|mb|gb|tb)$/i)
   .required()
+
+/**
+ * Joi validator that checks if a value is a relative-only URI
+ *
+ * @type {Joi}
+ */
+export const relativeUri = Joi.string().uri({ relativeOnly: true })
-- 
GitLab