Skip to content
Snippets Groups Projects
Unverified Commit 4f141dfa authored by ReubenFrankel's avatar ReubenFrankel Committed by GitHub
Browse files

[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: default avatarchris48s <chris48s@users.noreply.github.com>

* Update Gitea 404 message

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

---------

Co-authored-by: default avatarchris48s <chris48s@users.noreply.github.com>
parent 9fd55468
No related branches found
No related tags found
No related merge requests found
...@@ -8,7 +8,7 @@ To specify another instance like [codeberg](https://codeberg.org/), [forgejo](ht ...@@ -8,7 +8,7 @@ To specify another instance like [codeberg](https://codeberg.org/), [forgejo](ht
function httpErrorsFor() { function httpErrorsFor() {
return { return {
403: 'private repo', 403: 'private repo',
404: 'user or repo not found', 404: 'user, repo or path not found',
} }
} }
......
import Joi from 'joi' import Joi from 'joi'
import { age as ageColor } from '../color-formatters.js'
import { pathParam, queryParam } from '../index.js' import { pathParam, queryParam } from '../index.js'
import { optionalUrl } from '../validators.js'
import { formatDate } from '../text-formatters.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 GiteaBase from './gitea-base.js'
import { description, httpErrorsFor } from './gitea-helper.js' import { description, httpErrorsFor } from './gitea-helper.js'
...@@ -25,10 +25,11 @@ const schema = Joi.array() ...@@ -25,10 +25,11 @@ const schema = Joi.array()
const displayEnum = ['author', 'committer'] const displayEnum = ['author', 'committer']
const queryParamSchema = Joi.object({ const queryParamSchema = Joi.object({
gitea_url: optionalUrl, path: relativeUri,
display_timestamp: Joi.string() display_timestamp: Joi.string()
.valid(...displayEnum) .valid(...displayEnum)
.default('author'), .default('author'),
gitea_url: optionalUrl,
}).required() }).required()
export default class GiteaLastCommit extends GiteaBase { export default class GiteaLastCommit extends GiteaBase {
...@@ -54,6 +55,12 @@ export default class GiteaLastCommit extends GiteaBase { ...@@ -54,6 +55,12 @@ export default class GiteaLastCommit extends GiteaBase {
name: 'repo', name: 'repo',
example: 'tea', example: 'tea',
}), }),
queryParam({
name: 'path',
example: 'README.md',
schema: { type: 'string' },
description: 'File path to resolve the last commit for.',
}),
queryParam({ queryParam({
name: 'display_timestamp', name: 'display_timestamp',
example: 'committer', example: 'committer',
...@@ -84,6 +91,12 @@ export default class GiteaLastCommit extends GiteaBase { ...@@ -84,6 +91,12 @@ export default class GiteaLastCommit extends GiteaBase {
name: 'branch', name: 'branch',
example: 'main', example: 'main',
}), }),
queryParam({
name: 'path',
example: 'README.md',
schema: { type: 'string' },
description: 'File path to resolve the last commit for.',
}),
queryParam({ queryParam({
name: 'display_timestamp', name: 'display_timestamp',
example: 'committer', example: 'committer',
...@@ -108,12 +121,12 @@ export default class GiteaLastCommit extends GiteaBase { ...@@ -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 // https://gitea.com/api/swagger#/repository
return super.fetch({ return super.fetch({
schema, schema,
url: `${baseUrl}/api/v1/repos/${user}/${repo}/commits`, url: `${baseUrl}/api/v1/repos/${user}/${repo}/commits`,
options: { searchParams: { sha: branch } }, options: { searchParams: { sha: branch, path } },
httpErrors: httpErrorsFor(), httpErrors: httpErrorsFor(),
}) })
} }
...@@ -123,6 +136,7 @@ export default class GiteaLastCommit extends GiteaBase { ...@@ -123,6 +136,7 @@ export default class GiteaLastCommit extends GiteaBase {
{ {
gitea_url: baseUrl = 'https://gitea.com', gitea_url: baseUrl = 'https://gitea.com',
display_timestamp: displayTimestamp, display_timestamp: displayTimestamp,
path,
}, },
) { ) {
const body = await this.fetch({ const body = await this.fetch({
...@@ -130,6 +144,7 @@ export default class GiteaLastCommit extends GiteaBase { ...@@ -130,6 +144,7 @@ export default class GiteaLastCommit extends GiteaBase {
repo, repo,
branch, branch,
baseUrl, baseUrl,
path,
}) })
return this.constructor.render({ return this.constructor.render({
commitDate: body[0].commit[displayTimestamp].date, commitDate: body[0].commit[displayTimestamp].date,
......
...@@ -8,6 +8,41 @@ t.create('Last Commit (recent)').get('/gitea/tea.json').expectBadge({ ...@@ -8,6 +8,41 @@ t.create('Last Commit (recent)').get('/gitea/tea.json').expectBadge({
message: isFormattedDate, 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)') t.create('Last Commit (recent) (self-managed)')
.get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org') .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
.expectBadge({ .expectBadge({
...@@ -24,9 +59,23 @@ t.create('Last Commit (on-branch) (self-managed)') ...@@ -24,9 +59,23 @@ t.create('Last Commit (on-branch) (self-managed)')
message: isFormattedDate, 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') .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
.expectBadge({ .expectBadge({
label: 'last commit', 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',
}) })
import Joi from 'joi' 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 { 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 { GithubAuthV3Service } from './github-auth-service.js'
import { documentation, httpErrorsFor } from './github-helpers.js' import { documentation, httpErrorsFor } from './github-helpers.js'
...@@ -16,14 +17,14 @@ const schema = Joi.array() ...@@ -16,14 +17,14 @@ const schema = Joi.array()
date: Joi.string().required(), date: Joi.string().required(),
}).required(), }).required(),
}).required(), }).required(),
}).required(), }),
) )
.required() .required()
.min(1)
const displayEnum = ['author', 'committer'] const displayEnum = ['author', 'committer']
const queryParamSchema = Joi.object({ const queryParamSchema = Joi.object({
path: relativeUri,
display_timestamp: Joi.string() display_timestamp: Joi.string()
.valid(...displayEnum) .valid(...displayEnum)
.default('author'), .default('author'),
...@@ -45,6 +46,12 @@ export default class GithubLastCommit extends GithubAuthV3Service { ...@@ -45,6 +46,12 @@ export default class GithubLastCommit extends GithubAuthV3Service {
parameters: [ parameters: [
pathParam({ name: 'user', example: 'google' }), pathParam({ name: 'user', example: 'google' }),
pathParam({ name: 'repo', example: 'skia' }), pathParam({ name: 'repo', example: 'skia' }),
queryParam({
name: 'path',
example: 'README.md',
schema: { type: 'string' },
description: 'File path to resolve the last commit for.',
}),
queryParam({ queryParam({
name: 'display_timestamp', name: 'display_timestamp',
example: 'committer', example: 'committer',
...@@ -62,6 +69,12 @@ export default class GithubLastCommit extends GithubAuthV3Service { ...@@ -62,6 +69,12 @@ export default class GithubLastCommit extends GithubAuthV3Service {
pathParam({ name: 'user', example: 'google' }), pathParam({ name: 'user', example: 'google' }),
pathParam({ name: 'repo', example: 'skia' }), pathParam({ name: 'repo', example: 'skia' }),
pathParam({ name: 'branch', example: 'infra/config' }), 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({ queryParam({
name: 'display_timestamp', name: 'display_timestamp',
example: 'committer', example: 'committer',
...@@ -82,20 +95,24 @@ export default class GithubLastCommit extends GithubAuthV3Service { ...@@ -82,20 +95,24 @@ export default class GithubLastCommit extends GithubAuthV3Service {
} }
} }
async fetch({ user, repo, branch }) { async fetch({ user, repo, branch, path }) {
return this._requestJson({ return this._requestJson({
url: `/repos/${user}/${repo}/commits`, url: `/repos/${user}/${repo}/commits`,
options: { searchParams: { sha: branch } }, options: { searchParams: { sha: branch, path } },
schema, schema,
httpErrors: httpErrorsFor(), httpErrors: httpErrorsFor(),
}) })
} }
async handle({ user, repo, branch }, queryParams) { 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({ return this.constructor.render({
commitDate: body[0].commit[queryParams.display_timestamp].date, commitDate: commit[displayTimestamp].date,
}) })
} }
} }
...@@ -14,6 +14,26 @@ t.create('last commit (on branch)') ...@@ -14,6 +14,26 @@ t.create('last commit (on branch)')
.get('/badges/badgr.co/shielded.json') .get('/badges/badgr.co/shielded.json')
.expectBadge({ label: 'last commit', message: 'july 2013' }) .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)') t.create('last commit (by committer)')
.get('/badges/badgr.co/shielded.json?display_timestamp=committer') .get('/badges/badgr.co/shielded.json?display_timestamp=committer')
.expectBadge({ label: 'last commit', message: 'july 2013' }) .expectBadge({ label: 'last commit', message: 'july 2013' })
...@@ -21,3 +41,7 @@ t.create('last commit (by committer)') ...@@ -21,3 +41,7 @@ t.create('last commit (by committer)')
t.create('last commit (repo not found)') t.create('last commit (repo not found)')
.get('/badges/helmets.json') .get('/badges/helmets.json')
.expectBadge({ label: 'last commit', message: 'repo not found' }) .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' })
import Joi from 'joi' 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 { 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 GitLabBase from './gitlab-base.js'
import { description, httpErrorsFor } from './gitlab-helper.js'
const schema = Joi.array() const schema = Joi.array()
.items( .items(
Joi.object({ Joi.object({
committed_date: Joi.string().required(), committed_date: Joi.string().required(),
}).required(), }),
) )
.required() .required()
.min(1)
const queryParamSchema = Joi.object({ const queryParamSchema = Joi.object({
ref: Joi.string(), ref: Joi.string(),
gitlab_url: optionalUrl, gitlab_url: optionalUrl,
path: relativeUri,
}).required() }).required()
const refText = ` const refText = `
...@@ -53,6 +53,12 @@ export default class GitlabLastCommit extends GitLabBase { ...@@ -53,6 +53,12 @@ export default class GitlabLastCommit extends GitLabBase {
name: 'ref', name: 'ref',
example: 'master', 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 { ...@@ -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 // https://docs.gitlab.com/ee/api/commits.html#list-repository-commits
return super.fetch({ return super.fetch({
url: `${baseUrl}/api/v4/projects/${encodeURIComponent( url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
project, project,
)}/repository/commits`, )}/repository/commits`,
options: { searchParams: { ref_name: ref } }, options: { searchParams: { ref_name: ref, path } },
schema, schema,
httpErrors: httpErrorsFor('project not found'), httpErrors: httpErrorsFor('project not found'),
}) })
...@@ -81,9 +87,13 @@ export default class GitlabLastCommit extends GitLabBase { ...@@ -81,9 +87,13 @@ export default class GitlabLastCommit extends GitLabBase {
async handle( async handle(
{ project }, { project },
{ gitlab_url: baseUrl = 'https://gitlab.com', ref }, { gitlab_url: baseUrl = 'https://gitlab.com', ref, path },
) { ) {
const data = await this.fetch({ project, baseUrl, ref }) const data = await this.fetch({ project, baseUrl, ref, path })
return this.constructor.render({ commitDate: data[0].committed_date }) const [commit] = data
if (!commit) throw new NotFound({ prettyMessage: 'no commits found' })
return this.constructor.render({ commitDate: commit.committed_date })
} }
} }
...@@ -8,13 +8,43 @@ t.create('last commit (recent)').get('/gitlab-org/gitlab.json').expectBadge({ ...@@ -8,13 +8,43 @@ t.create('last commit (recent)').get('/gitlab-org/gitlab.json').expectBadge({
message: isFormattedDate, 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') .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee')
.expectBadge({ .expectBadge({
label: 'last commit', label: 'last commit',
message: 'march 2021', 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)') t.create('last commit (self-managed)')
.get('/gitlab-cn/gitlab.json?gitlab_url=https://jihulab.com') .get('/gitlab-cn/gitlab.json?gitlab_url=https://jihulab.com')
.expectBadge({ .expectBadge({
...@@ -28,3 +58,10 @@ t.create('last commit (project not found)') ...@@ -28,3 +58,10 @@ t.create('last commit (project not found)')
label: 'last commit', label: 'last commit',
message: 'project not found', 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',
})
...@@ -79,3 +79,10 @@ export const optionalUrl = Joi.string().uri({ scheme: ['http', 'https'] }) ...@@ -79,3 +79,10 @@ export const optionalUrl = Joi.string().uri({ scheme: ['http', 'https'] })
export const fileSize = Joi.string() export const fileSize = Joi.string()
.regex(/^[0-9]+(b|kb|mb|gb|tb)$/i) .regex(/^[0-9]+(b|kb|mb|gb|tb)$/i)
.required() .required()
/**
* Joi validator that checks if a value is a relative-only URI
*
* @type {Joi}
*/
export const relativeUri = Joi.string().uri({ relativeOnly: true })
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment