diff --git a/.eslintrc.json b/.eslintrc.json index 3d4543c..4316e1e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -29,6 +29,7 @@ "error", { "ignore": ["csv-parse/sync"] } - ] + ], + "jest/no-disabled-tests": 0 } } diff --git a/__tests__/buildkit/config.test.ts b/__tests__/buildkit/config.test.ts index 7a90ec9..c3ac51d 100644 --- a/__tests__/buildkit/config.test.ts +++ b/__tests__/buildkit/config.test.ts @@ -45,7 +45,7 @@ afterEach(() => { rimraf.sync(tmpDir); }); -describe('generate', () => { +describe('resolve', () => { test.each([ ['debug = true', false, 'debug = true', null], [`notfound.toml`, true, '', new Error('config file notfound.toml not found')], @@ -65,9 +65,9 @@ describe('generate', () => { }); let config: string; if (file) { - config = buildkit.config.generateFromFile(val); + config = buildkit.config.resolveFromFile(val); } else { - config = buildkit.config.generateFromString(val); + config = buildkit.config.resolveFromString(val); } expect(config).toEqual(tmpName); const configValue = fs.readFileSync(tmpName, 'utf-8'); diff --git a/__tests__/dockerhub.test.ts b/__tests__/dockerhub.test.ts new file mode 100644 index 0000000..187bdf0 --- /dev/null +++ b/__tests__/dockerhub.test.ts @@ -0,0 +1,71 @@ +/** + * Copyright 2023 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {describe, expect, jest, it, beforeEach} from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; + +import {DockerHub} from '../src/dockerhub'; +import {RepositoryResponse} from '../src/types/dockerhub'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +import repoInfoFixture from './fixtures/dockerhub-repoinfo.json'; + +describe('getRepository', () => { + it('returns repo info', async () => { + jest.spyOn(DockerHub.prototype, 'getRepository').mockImplementation((): Promise => { + return >(repoInfoFixture as unknown); + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(DockerHub as any, 'login').mockReturnValue('jwt_token'); + const dockerhub = await DockerHub.build({ + credentials: { + username: 'foo', + password: '0123456-7890-0000-1111-222222222' + } + }); + const repoinfo = await dockerhub.getRepository({ + namespace: 'foo', + name: 'bar' + }); + expect(repoinfo.namespace).toEqual('foo'); + expect(repoinfo.name).toEqual('bar'); + expect(repoinfo.repository_type).toEqual('image'); + }); +}); + +describe('updateRepoDescription', () => { + it.skip('set repo description', async () => { + const dockerhub = await DockerHub.build({ + credentials: { + username: 'foo', + password: 'bar' + } + }); + const resp = await dockerhub.updateRepoDescription({ + namespace: 'crazymax', + name: 'test-toolkit', + description: 'Hello-World', + full_description: fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf-8') + }); + expect(resp.namespace).toEqual('foo'); + expect(resp.name).toEqual('bar'); + expect(resp.description).toEqual('Hello-World'); + }); +}); diff --git a/__tests__/fixtures/dockerhub-repoinfo.json b/__tests__/fixtures/dockerhub-repoinfo.json new file mode 100644 index 0000000..2ef115a --- /dev/null +++ b/__tests__/fixtures/dockerhub-repoinfo.json @@ -0,0 +1,34 @@ +{ + "user": "foo", + "name": "bar", + "namespace": "foo", + "repository_type": "image", + "status": 1, + "status_description": "active", + "description": "Bar", + "is_private": false, + "is_automated": false, + "can_edit": true, + "star_count": 68, + "pull_count": 178255909, + "last_updated": "2023-02-02T14:22:31.184404Z", + "date_registered": "2019-06-04T20:08:57.306333Z", + "collaborator_count": 0, + "affiliation": "owner", + "hub_user": "foo", + "has_starred": false, + "full_description": "This is the full description of the repo.", + "permissions": { + "read": true, + "write": true, + "admin": true + }, + "media_types": [ + "application/vnd.docker.container.image.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.index.v1+json" + ], + "content_types": [ + "image" + ] +} diff --git a/__tests__/fixtures/repo.json b/__tests__/fixtures/github-repo.json similarity index 100% rename from __tests__/fixtures/repo.json rename to __tests__/fixtures/github-repo.json diff --git a/__tests__/github.test.ts b/__tests__/github.test.ts index 08c73a4..8880152 100644 --- a/__tests__/github.test.ts +++ b/__tests__/github.test.ts @@ -26,7 +26,7 @@ beforeEach(() => { jest.clearAllMocks(); }); -import repoFixture from './fixtures/repo.json'; +import repoFixture from './fixtures/github-repo.json'; jest.spyOn(GitHub.prototype, 'repoData').mockImplementation((): Promise => { return >(repoFixture as unknown); }); diff --git a/src/buildkit/config.ts b/src/buildkit/config.ts index b694f06..6da6ef3 100644 --- a/src/buildkit/config.ts +++ b/src/buildkit/config.ts @@ -25,15 +25,15 @@ export class Config { this.context = context; } - public generateFromString(s: string): string { - return this.generate(s, false); + public resolveFromString(s: string): string { + return this.resolve(s, false); } - public generateFromFile(s: string): string { - return this.generate(s, true); + public resolveFromFile(s: string): string { + return this.resolve(s, true); } - private generate(s: string, file: boolean): string { + private resolve(s: string, file: boolean): string { if (file) { if (!fs.existsSync(s)) { throw new Error(`config file ${s} not found`); diff --git a/src/dockerhub.ts b/src/dockerhub.ts new file mode 100644 index 0000000..0e3c65a --- /dev/null +++ b/src/dockerhub.ts @@ -0,0 +1,97 @@ +/** + * Copyright 2023 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as core from '@actions/core'; +import * as httpm from '@actions/http-client'; +import {HttpCodes} from '@actions/http-client'; + +import {RepositoryRequest, RepositoryResponse, TokenRequest, TokenResponse, UpdateRepoDescriptionRequest} from './types/dockerhub'; + +export interface DockerHubOpts { + credentials: TokenRequest; +} + +const apiBaseURL = 'https://hub.docker.com'; +const loginURL = `${apiBaseURL}/v2/users/login?refresh_token=true`; +const repositoriesURL = `${apiBaseURL}/v2/repositories/`; + +export class DockerHub { + private readonly opts: DockerHubOpts; + private readonly httpc: httpm.HttpClient; + + private constructor(opts: DockerHubOpts, httpc: httpm.HttpClient) { + this.opts = opts; + this.httpc = httpc; + } + + public static async build(opts: DockerHubOpts): Promise { + return new DockerHub( + opts, + new httpm.HttpClient('docker-actions-toolkit', [], { + headers: { + Authorization: `JWT ${await DockerHub.login(opts.credentials)}`, + 'Content-Type': 'application/json' + } + }) + ); + } + + public async getRepository(req: RepositoryRequest): Promise { + const resp: httpm.HttpClientResponse = await this.httpc.get(`${repositoriesURL}${req.namespace}/${req.name}`); + return JSON.parse(await DockerHub.handleResponse(resp)); + } + + public async updateRepoDescription(req: UpdateRepoDescriptionRequest): Promise { + const body = { + full_description: req.full_description + }; + if (req.description) { + body['description'] = req.description; + } + const resp: httpm.HttpClientResponse = await this.httpc.patch(`${repositoriesURL}${req.namespace}/${req.name}`, JSON.stringify(body)); + return JSON.parse(await DockerHub.handleResponse(resp)); + } + + private static async login(req: TokenRequest): Promise { + const http: httpm.HttpClient = new httpm.HttpClient('docker-actions-toolkit', [], { + headers: { + 'Content-Type': 'application/json' + } + }); + const resp: httpm.HttpClientResponse = await http.post(loginURL, JSON.stringify(req)); + const tokenResp = JSON.parse(await DockerHub.handleResponse(resp)); + core.setSecret(`${tokenResp.token}`); + return `${tokenResp.token}`; + } + + private static async handleResponse(resp: httpm.HttpClientResponse): Promise { + const body = await resp.readBody(); + resp.message.statusCode = resp.message.statusCode || HttpCodes.InternalServerError; + if (resp.message.statusCode < 200 || resp.message.statusCode >= 300) { + if (resp.message.statusCode == HttpCodes.Unauthorized) { + throw new Error(`Docker Hub API: operation not permitted`); + } + const errResp = >JSON.parse(body); + for (const k of ['message', 'detail', 'error']) { + if (errResp[k]) { + throw new Error(`Docker Hub API: bad status code ${resp.message.statusCode}: ${errResp[k]}`); + } + } + throw new Error(`Docker Hub API: bad status code ${resp.message.statusCode}`); + } + return body; + } +} diff --git a/src/types/dockerhub.ts b/src/types/dockerhub.ts new file mode 100644 index 0000000..65fd0e0 --- /dev/null +++ b/src/types/dockerhub.ts @@ -0,0 +1,66 @@ +/** + * Copyright 2023 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface TokenRequest { + username: string; + password: string; +} + +export interface TokenResponse { + token: string; + detail: string; +} + +export interface RepositoryRequest { + namespace: string; + name: string; +} + +export interface RepositoryResponse { + user: string; + name: string; + namespace: string; + repository_type: string; + status: number; + status_description: string; + description: string; + is_private: boolean; + is_automated: boolean; + can_edit: boolean; + star_count: number; + pull_count: number; + last_updated: string; + date_registered: string; + collaborator_count: number; + affiliation: string; + hub_user: string; + has_starred: boolean; + full_description: string; + permissions: { + read: boolean; + write: boolean; + admin: boolean; + }; + media_types: Array; + content_types: Array; +} + +export interface UpdateRepoDescriptionRequest { + name: string; + namespace: string; + description?: string; + full_description: string; +}