diff --git a/.env.example b/.env.example index 892ff5b3..f55dfdf1 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ OTA_ENGINE_SENDINBLUE_API_KEY='xkeysib-3f51c…' OTA_ENGINE_SMTP_PASSWORD='password' + +# If both GitHub and GitLab tokens are defined, GitHub takes precedence for dataset publishing OTA_ENGINE_GITHUB_TOKEN=ghp_XXXXXXXXX + +OTA_ENGINE_GITLAB_TOKEN=XXXXXXXXXX +OTA_ENGINE_GITLAB_RELEASES_TOKEN=XXXXXXXXXX diff --git a/CHANGELOG.md b/CHANGELOG.md index a6dc587c..d76a1acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All changes that impact users of this module are documented in this file, in the [Common Changelog](https://common-changelog.org) format with some additional specifications defined in the CONTRIBUTING file. This codebase adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased [minor] + +> Development of this release was supported by the [European Union](https://commission.europa.eu/) and the [French Ministry for Foreign Affairs](https://www.diplomatie.gouv.fr/fr/politique-etrangere-de-la-france/diplomatie-numerique/) through its ministerial [State Startups incubator](https://beta.gouv.fr/startups/open-terms-archive.html) under the aegis of the Ambassador for Digital Affairs. + +### Added + +- Add support for GitLab for issue reporting +- Add support for GitLab Releases for publishing datasets + ## 2.5.0 - 2024-10-29 _Full changeset and discussions: [#1115](https://github.com/OpenTermsArchive/engine/pull/1115)._ diff --git a/package-lock.json b/package-lock.json index 5d7c06bc..37184856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -171,6 +171,14 @@ "npm": ">=6" } }, + "node_modules/@accordproject/concerto-util/node_modules/axios": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", + "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==", + "dependencies": { + "follow-redirects": "^1.14.4" + } + }, "node_modules/@accordproject/markdown-cicero": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/@accordproject/markdown-cicero/-/markdown-cicero-0.15.2.tgz", @@ -324,6 +332,14 @@ "npm": ">=6" } }, + "node_modules/@accordproject/markdown-pdf/node_modules/axios": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", + "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==", + "dependencies": { + "follow-redirects": "^1.14.4" + } + }, "node_modules/@accordproject/markdown-pdf/node_modules/pdfjs-dist": { "version": "2.13.216", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.13.216.tgz", @@ -4753,14 +4769,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/axios": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", - "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==", - "dependencies": { - "follow-redirects": "^1.14.4" - } - }, "node_modules/b4a": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", @@ -7296,9 +7304,9 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", diff --git a/scripts/dataset/publish/github/index.js b/scripts/dataset/publish/github/index.js new file mode 100644 index 00000000..54d54b44 --- /dev/null +++ b/scripts/dataset/publish/github/index.js @@ -0,0 +1,36 @@ +import fsApi from 'fs'; +import path from 'path'; +import url from 'url'; + +import config from 'config'; +import { Octokit } from 'octokit'; + +import * as readme from '../../assets/README.template.js'; + +export default async function publish({ archivePath, releaseDate, stats }) { + const octokit = new Octokit({ auth: process.env.OTA_ENGINE_GITHUB_TOKEN }); + + const [ owner, repo ] = url.parse(config.get('@opentermsarchive/engine.dataset.versionsRepositoryURL')).pathname.split('/').filter(component => component); + + const tagName = `${path.basename(archivePath, path.extname(archivePath))}`; // use archive filename as Git tag + + const { data: { upload_url: uploadUrl, html_url: releaseUrl } } = await octokit.rest.repos.createRelease({ + owner, + repo, + tag_name: tagName, + name: readme.title({ releaseDate }), + body: readme.body(stats), + }); + + await octokit.rest.repos.uploadReleaseAsset({ + data: fsApi.readFileSync(archivePath), + headers: { + 'content-type': 'application/zip', + 'content-length': fsApi.statSync(archivePath).size, + }, + name: path.basename(archivePath), + url: uploadUrl, + }); + + return releaseUrl; +} diff --git a/scripts/dataset/publish/gitlab/index.js b/scripts/dataset/publish/gitlab/index.js new file mode 100644 index 00000000..1b3c798f --- /dev/null +++ b/scripts/dataset/publish/gitlab/index.js @@ -0,0 +1,133 @@ +import fsApi from 'fs'; +import path from 'path'; + +import config from 'config'; +import dotenv from 'dotenv'; +import FormData from 'form-data'; +import nodeFetch from 'node-fetch'; + +import GitLab from '../../../../src/reporter/gitlab/index.js'; +import * as readme from '../../assets/README.template.js'; +import logger from '../../logger/index.js'; + +dotenv.config(); + +export default async function publish({ + archivePath, + releaseDate, + stats, +}) { + let projectId = null; + const gitlabAPIUrl = config.get('@opentermsarchive/engine.dataset.apiBaseURL'); + + const [ owner, repo ] = new URL(config.get('@opentermsarchive/engine.dataset.versionsRepositoryURL')) + .pathname + .split('/') + .filter(Boolean); + const commonParams = { owner, repo }; + + try { + const repositoryPath = `${commonParams.owner}/${commonParams.repo}`; + + const options = GitLab.baseOptionsHttpReq(process.env.OTA_ENGINE_GITLAB_RELEASES_TOKEN); + + options.method = 'GET'; + options.headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + const response = await nodeFetch( + `${gitlabAPIUrl}/projects/${encodeURIComponent(repositoryPath)}`, + options, + ); + const res = await response.json(); + + projectId = res.id; + } catch (error) { + logger.error(`Error while obtaining projectId: ${error}`); + projectId = null; + } + + const tagName = `${path.basename(archivePath, path.extname(archivePath))}`; // use archive filename as Git tag + + try { + let options = GitLab.baseOptionsHttpReq(process.env.OTA_ENGINE_GITLAB_RELEASES_TOKEN); + + options.method = 'POST'; + options.body = { + ref: 'main', + tag_name: tagName, + name: readme.title({ releaseDate }), + description: readme.body(stats), + }; + options.headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + options.body = JSON.stringify(options.body); + + const releaseResponse = await nodeFetch( + `${gitlabAPIUrl}/projects/${projectId}/releases`, + options, + ); + const releaseRes = await releaseResponse.json(); + + const releaseId = releaseRes.commit.id; + + logger.info(`Created release with releaseId: ${releaseId}`); + + // Upload the package + options = GitLab.baseOptionsHttpReq(process.env.OTA_ENGINE_GITLAB_RELEASES_TOKEN); + options.method = 'PUT'; + options.body = fsApi.createReadStream(archivePath); + + // restrict characters to the ones allowed by GitLab APIs + const packageName = config.get('@opentermsarchive/engine.dataset.title').replace(/[^a-zA-Z0-9.\-_]/g, '-'); + const packageVersion = tagName.replace(/[^a-zA-Z0-9.\-_]/g, '-'); + const packageFileName = archivePath.replace(/[^a-zA-Z0-9.\-_/]/g, '-'); + + logger.debug(`packageName: ${packageName}, packageVersion: ${packageVersion} packageFileName: ${packageFileName}`); + + const packageResponse = await nodeFetch( + `${gitlabAPIUrl}/projects/${projectId}/packages/generic/${packageName}/${packageVersion}/${packageFileName}?status=default&select=package_file`, + options, + ); + const packageRes = await packageResponse.json(); + + const packageFilesId = packageRes.id; + + logger.debug(`package file id: ${packageFilesId}`); + + // use the package id to build the download url for the release + const publishedPackageUrl = `${config.get('@opentermsarchive/engine.dataset.versionsRepositoryURL')}/-/package_files/${packageFilesId}/download`; + + // Create the release and link the package + const formData = new FormData(); + + formData.append('name', archivePath); + formData.append('url', publishedPackageUrl); + formData.append('file', fsApi.createReadStream(archivePath), { filename: path.basename(archivePath) }); + + options = GitLab.baseOptionsHttpReq(process.env.OTA_ENGINE_GITLAB_RELEASES_TOKEN); + options.method = 'POST'; + options.headers = { + ...formData.getHeaders(), + ...options.headers, + }; + options.body = formData; + + const uploadResponse = await nodeFetch( + `${gitlabAPIUrl}/projects/${projectId}/releases/${tagName}/assets/links`, + options, + ); + const uploadRes = await uploadResponse.json(); + const releaseUrl = uploadRes.direct_asset_url; + + return releaseUrl; + } catch (error) { + logger.error('Failed to create release or upload ZIP file:', error); + throw error; + } +} diff --git a/scripts/dataset/publish/index.js b/scripts/dataset/publish/index.js index 89e0f7fe..6ed8ead0 100644 --- a/scripts/dataset/publish/index.js +++ b/scripts/dataset/publish/index.js @@ -1,36 +1,15 @@ -import fsApi from 'fs'; -import path from 'path'; -import url from 'url'; +import publishGitHub from './github/index.js'; +import publishGitLab from './gitlab/index.js'; -import config from 'config'; -import { Octokit } from 'octokit'; +export default function publishRelease({ archivePath, releaseDate, stats }) { + // If both GitHub and GitLab tokens are defined, GitHub takes precedence + if (process.env.OTA_ENGINE_GITHUB_TOKEN) { + return publishGitHub({ archivePath, releaseDate, stats }); + } -import * as readme from '../assets/README.template.js'; + if (process.env.OTA_ENGINE_GITLAB_TOKEN) { + return publishGitLab({ archivePath, releaseDate, stats }); + } -export default async function publish({ archivePath, releaseDate, stats }) { - const octokit = new Octokit({ auth: process.env.OTA_ENGINE_GITHUB_TOKEN }); - - const [ owner, repo ] = url.parse(config.get('@opentermsarchive/engine.dataset.versionsRepositoryURL')).pathname.split('/').filter(component => component); - - const tagName = `${path.basename(archivePath, path.extname(archivePath))}`; // use archive filename as Git tag - - const { data: { upload_url: uploadUrl, html_url: releaseUrl } } = await octokit.rest.repos.createRelease({ - owner, - repo, - tag_name: tagName, - name: readme.title({ releaseDate }), - body: readme.body(stats), - }); - - await octokit.rest.repos.uploadReleaseAsset({ - data: fsApi.readFileSync(archivePath), - headers: { - 'content-type': 'application/zip', - 'content-length': fsApi.statSync(archivePath).size, - }, - name: path.basename(archivePath), - url: uploadUrl, - }); - - return releaseUrl; + throw new Error('No GitHub nor GitLab token found in environment variables (OTA_ENGINE_GITHUB_TOKEN or OTA_ENGINE_GITLAB_TOKEN). Cannot publish the dataset without authentication.'); } diff --git a/src/index.js b/src/index.js index 2083e180..8b011133 100644 --- a/src/index.js +++ b/src/index.js @@ -55,21 +55,17 @@ export default async function track({ services, types, extractOnly, schedule }) logger.warn('Environment variable "OTA_ENGINE_SENDINBLUE_API_KEY" was not found; the Notifier module will be ignored'); } - if (process.env.OTA_ENGINE_GITHUB_TOKEN) { - if (config.has('@opentermsarchive/engine.reporter.githubIssues.repositories.declarations')) { - try { - const reporter = new Reporter(config.get('@opentermsarchive/engine.reporter')); - - await reporter.initialize(); - archivist.attach(reporter); - } catch (error) { - logger.error('Cannot instantiate the Reporter module; it will be ignored:', error); - } - } else { - logger.warn('Configuration key "reporter.githubIssues.repositories.declarations" was not found; issues on the declarations repository cannot be created'); + if (process.env.OTA_ENGINE_GITHUB_TOKEN || process.env.OTA_ENGINE_GITLAB_TOKEN) { + try { + const reporter = new Reporter(config.get('@opentermsarchive/engine.reporter')); + + await reporter.initialize(); + archivist.attach(reporter); + } catch (error) { + logger.error('Cannot instantiate the Reporter module; it will be ignored:', error); } } else { - logger.warn('Environment variable "OTA_ENGINE_GITHUB_TOKEN" was not found; the Reporter module will be ignored'); + logger.warn('Environment variable with token for GitHub or GitLab was not found; the Reporter module will be ignored'); } if (!schedule) { diff --git a/src/reporter/factory.js b/src/reporter/factory.js new file mode 100644 index 00000000..f8619277 --- /dev/null +++ b/src/reporter/factory.js @@ -0,0 +1,13 @@ +import GitHub from './github/index.js'; +import GitLab from './gitlab/index.js'; + +export function createReporter(config) { + switch (config.type) { + case 'github': + return new GitHub(config.repositories.declarations); + case 'gitlab': + return new GitLab(config.repositories.declarations, config.baseURL, config.apiBaseURL); + default: + throw new Error(`Unsupported reporter type: ${config.type}`); + } +} diff --git a/src/reporter/github.js b/src/reporter/github/index.js similarity index 89% rename from src/reporter/github.js rename to src/reporter/github/index.js index 535d008a..28d262de 100644 --- a/src/reporter/github.js +++ b/src/reporter/github/index.js @@ -2,7 +2,7 @@ import { createRequire } from 'module'; import { Octokit } from 'octokit'; -import logger from '../logger/index.js'; +import logger from '../../logger/index.js'; const require = createRequire(import.meta.url); @@ -14,7 +14,7 @@ export default class GitHub { static ISSUE_STATE_ALL = 'all'; constructor(repository) { - const { version } = require('../../package.json'); + const { version } = require('../../../package.json'); this.octokit = new Octokit({ auth: process.env.OTA_ENGINE_GITHUB_TOKEN, @@ -198,4 +198,16 @@ export default class GitHub { logger.error(`Failed to update issue "${title}": ${error.stack}`); } } + + generateDeclarationURL(serviceName) { + return `https://github.com/${this.commonParams.owner}/${this.commonParams.repo}/blob/main/declarations/${encodeURIComponent(serviceName)}.json`; + } + + generateVersionURL(serviceName, termsType) { + return `https://github.com/${this.commonParams.owner}/${this.commonParams.repo}/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}.md`; + } + + generateSnapshotsBaseUrl(serviceName, termsType) { + return `https://github.com/${this.commonParams.owner}/${this.commonParams.repo}/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}`; + } } diff --git a/src/reporter/github.test.js b/src/reporter/github/index.test.js similarity index 99% rename from src/reporter/github.test.js rename to src/reporter/github/index.test.js index 5aa21f18..7a126413 100644 --- a/src/reporter/github.test.js +++ b/src/reporter/github/index.test.js @@ -3,7 +3,7 @@ import { createRequire } from 'module'; import { expect } from 'chai'; import nock from 'nock'; -import GitHub from './github.js'; +import GitHub from './index.js'; const require = createRequire(import.meta.url); diff --git a/src/reporter/labels.json b/src/reporter/github/labels.json similarity index 100% rename from src/reporter/labels.json rename to src/reporter/github/labels.json diff --git a/src/reporter/labels.test.js b/src/reporter/github/labels.test.js similarity index 94% rename from src/reporter/labels.test.js rename to src/reporter/github/labels.test.js index c7fc9fd4..a817a806 100644 --- a/src/reporter/labels.test.js +++ b/src/reporter/github/labels.test.js @@ -2,7 +2,7 @@ import { createRequire } from 'module'; import chai from 'chai'; -import { MANAGED_BY_OTA_MARKER } from './github.js'; +import { MANAGED_BY_OTA_MARKER } from './index.js'; const require = createRequire(import.meta.url); diff --git a/src/reporter/gitlab/index.js b/src/reporter/gitlab/index.js new file mode 100644 index 00000000..e427fbc8 --- /dev/null +++ b/src/reporter/gitlab/index.js @@ -0,0 +1,386 @@ +import { createRequire } from 'module'; + +import HttpProxyAgent from 'http-proxy-agent'; +import HttpsProxyAgent from 'https-proxy-agent'; +import nodeFetch from 'node-fetch'; + +import logger from '../../logger/index.js'; + +const require = createRequire(import.meta.url); + +export const MANAGED_BY_OTA_MARKER = '[managed by OTA]'; +const BASE_URL = 'https://gitlab.com'; +const API_BASE_URL = 'https://gitlab.com/api/v4'; + +export default class GitLab { + static ISSUE_STATE_CLOSED = 'closed'; + static ISSUE_STATE_OPEN = 'opened'; + static ISSUE_STATE_ALL = 'all'; + + constructor(repository, baseURL = BASE_URL, apiBaseURL = API_BASE_URL) { + const [ owner, repo ] = repository.split('/'); + + this.commonParams = { owner, repo }; + this.projectId = null; + this.baseURL = baseURL; + console.log('this.baseURL', this.baseURL); + this.apiBaseURL = apiBaseURL; + } + + async initialize() { + const options = GitLab.baseOptionsHttpReq(); + + try { + const repositoryPath = `${this.commonParams.owner}/${this.commonParams.repo}`; + const response = await nodeFetch( + `${this.apiBaseURL}/projects/${encodeURIComponent(repositoryPath)}`, + options, + ); + + const res = await response.json(); + + if (response.ok) { + this.projectId = res.id; + } else { + logger.error(`Error while obtaining projectId: ${JSON.strinfigy(res)}`); + this.projectId = null; + } + } catch (error) { + logger.error(`Error while obtaining projectId: ${error}`); + this.projectId = null; + } + + this.MANAGED_LABELS = require('./labels.json'); + + const existingLabels = await this.getRepositoryLabels(); + const existingLabelsNames = existingLabels.map(label => label.name); + const missingLabels = this.MANAGED_LABELS.filter(label => !existingLabelsNames.includes(label.name)); + + if (missingLabels.length) { + logger.info(`The following required labels are not present on the repository: ${missingLabels.map(label => `"${label.name}"`).join(', ')}. Creating them…`); + + for (const label of missingLabels) { + await this.createLabel({ /* eslint-disable-line no-await-in-loop */ + name: label.name, + color: label.color, + description: `${label.description} ${MANAGED_BY_OTA_MARKER}`, + }); + } + } + } + + async getRepositoryLabels() { + try { + const options = GitLab.baseOptionsHttpReq(); + const response = await nodeFetch( + `${this.apiBaseURL}/projects/${this.projectId}/labels?with_counts=true`, + options, + ); + + const res = await response.json(); + + if (response.ok) { + return res; + } + + logger.error(`Failed to get labels: ${response.status} - ${JSON.stringify(res)}`); + + return null; + } catch (error) { + logger.error(`Could not get labels: ${error}`); + } + } + + async createLabel({ name, color, description }) { + try { + const label = { + name, + color, + description, + }; + + const options = GitLab.baseOptionsHttpReq(); + + options.method = 'POST'; + options.body = JSON.stringify(label); + options.headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + const response = await nodeFetch( + `${this.apiBaseURL}/projects/${this.projectId}/labels`, + options, + ); + + const res = await response.json(); + + if (response.ok) { + logger.info(`New label created: ${res.name} , color: ${res.color}`); + } else { + logger.error(`createLabel response: ${JSON.stringify(res)}`); + } + } catch (error) { + logger.error(`Failed to create label: ${error}`); + } + } + + async createIssue({ title, description, labels }) { + try { + const issue = { + title, + labels, + description, + }; + + const options = GitLab.baseOptionsHttpReq(); + + options.method = 'POST'; + options.body = JSON.stringify(issue); + options.headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + const response = await nodeFetch( + `${this.apiBaseURL}/projects/${this.projectId}/issues`, + options, + ); + + const res = await response.json(); + + if (response.ok) { + logger.info(`Created GitLab issue #${res.iid} "${title}": ${res.web_url}`); + + return res; + } + + logger.error(`createIssue response: ${JSON.stringify(res)}`); + } catch (error) { + logger.error(`Could not create GitLab issue "${title}": ${error}`); + } + } + + async setIssueLabels({ issue, labels }) { + const newLabels = { labels }; + const options = GitLab.baseOptionsHttpReq(); + + options.method = 'PUT'; + options.body = JSON.stringify(newLabels); + options.headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + try { + const response = await nodeFetch( + `${this.apiBaseURL}/projects/${this.projectId}/issues/${issue.iid}`, + options, + ); + + const res = await response.json(); + + if (response.ok) { + logger.info(`Updated labels to GitLab issue #${issue.iid}`); + } else { + logger.error(`setIssueLabels response: ${JSON.stringify(res)}`); + } + } catch (error) { + logger.error(`Could not update GitLab issue #${issue.iid} "${issue.title}": ${error}`); + } + } + + async openIssue(issue) { + const updateIssue = { state_event: 'reopen' }; + const options = GitLab.baseOptionsHttpReq(); + + options.method = 'PUT'; + options.body = JSON.stringify(updateIssue); + options.headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + try { + const response = await nodeFetch( + `${this.apiBaseURL}/projects/${this.projectId}/issues/${issue.iid}`, + options, + ); + const res = await response.json(); + + if (response.ok) { + logger.info(`Opened GitLab issue #${res.iid}`); + } else { + logger.error(`openIssue response: ${JSON.stringify(res)}`); + } + } catch (error) { + logger.error(`Could not update GitLab issue #${issue.iid} "${issue.title}": ${error}`); + } + } + + async closeIssue(issue) { + const updateIssue = { state_event: 'close' }; + + const options = GitLab.baseOptionsHttpReq(); + + options.method = 'PUT'; + options.body = JSON.stringify(updateIssue); + options.headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + try { + const response = await nodeFetch( + `${this.apiBaseURL}/projects/${this.projectId}/issues/${issue.iid}`, + options, + ); + const res = await response.json(); + + if (response.ok) { + logger.info(`Closed GitLab issue #${issue.iid}`); + } else { + logger.error(`closeIssue response: ${JSON.stringify(res)}`); + } + } catch (error) { + logger.error(`Could not update GitLab issue #${issue.iid} "${issue.title}": ${error}`); + } + } + + async getIssue({ title, ...searchParams }) { + try { + let apiUrl = `${this.apiBaseURL}/projects/${this.projectId}/issues?state=${searchParams.state}&per_page=100`; + + if (searchParams.state == 'all') apiUrl = `${this.apiBaseURL}/projects/${this.projectId}/issues?per_page=100`; + apiUrl = `${this.apiBaseURL}/projects/${this.projectId}/issues?search=${encodeURIComponent(title)}&per_page=100`; + + const options = GitLab.baseOptionsHttpReq(); + + options.method = 'GET'; + + const response = await nodeFetch(apiUrl, options); + const res = await response.json(); + + if (response.ok) { + const issues = res; + + const [issue] = issues.filter(item => item.title === title); // since only one is expected, use the first one + + return issue; + } + + logger.error(`openIssue response: ${JSON.stringify(res)}`); + } catch (error) { + logger.error(`Could not find GitLab issue "${title}": ${error}`); + } + } + + async addCommentToIssue({ issue, comment }) { + const body = { body: comment }; + + const options = GitLab.baseOptionsHttpReq(); + + options.method = 'POST'; + options.body = JSON.stringify(body); + options.headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + try { + const response = await nodeFetch( + `${this.apiBaseURL}/projects/${this.projectId}/issues/${issue.iid}/notes`, + options, + ); + const res = await response.json(); + + if (response.ok) { + logger.info(`Added comment to GitLab issue #${issue.iid} ${issue.title}: ${res.id}`); + + return res.body; + } + + logger.error(`openIssue response: ${JSON.stringify(res)}`); + } catch (error) { + logger.error(`Could not add comment to GitLab issue #${issue.iid} "${issue.title}": ${error}`); + } + } + + async closeIssueWithCommentIfExists({ title, comment }) { + const issue = await this.getIssue({ + title, + state: GitLab.ISSUE_STATE_OPEN, + }); + + // if issue does not exist in the "opened" state + if (!issue) { + return; + } + + await this.addCommentToIssue({ issue, comment }); + + return this.closeIssue(issue); + } + + async createOrUpdateIssue({ title, description, label }) { + const issue = await this.getIssue({ title, state: GitLab.ISSUE_STATE_ALL }); + + if (!issue) { + return this.createIssue({ title, description, labels: [label] }); + } + + if (issue.state == GitLab.ISSUE_STATE_CLOSED) { + await this.openIssue(issue); + } + + const managedLabelsNames = this.MANAGED_LABELS.map(label => label.name); + const [managedLabel] = issue.labels.filter(label => + managedLabelsNames.includes(label.name)); // it is assumed that only one specific reason for failure is possible at a time, making managed labels mutually exclusive + + if (managedLabel?.name == label) { + // if the label is already assigned to the issue, the error is redundant with the one already reported and no further action is necessary + return; + } + + const labelsNotManagedToKeep = issue.labels + .map(label => label.name) + .filter(label => !managedLabelsNames.includes(label)); + + await this.setIssueLabels({ + issue, + labels: [ label, ...labelsNotManagedToKeep ], + }); + await this.addCommentToIssue({ issue, comment: description }); + } + + static baseOptionsHttpReq(token = process.env.OTA_ENGINE_GITLAB_TOKEN) { + const options = {}; + + if (process.env.HTTPS_PROXY) { + options.agent = new HttpsProxyAgent(process.env.HTTPS_PROXY); + } else if (process.env.HTTP_PROXY) { + options.agent = new HttpProxyAgent(process.env.HTTP_PROXY); + } + + options.headers = { Authorization: `Bearer ${token}` }; + + return options; + } + + generateDeclarationURL(serviceName) { + return `${this.baseURL}/${this.commonParams.owner}/${this.commonParams.repo}/-/blob/main/declarations/${encodeURIComponent(serviceName)}.json`; + } + + generateVersionURL(serviceName, termsType) { + return `${this.baseURL}/${this.commonParams.owner}/${this.commonParams.repo}/-/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(serviceName, termsType)}.md`; + } + + generateSnapshotsBaseUrl(serviceName, termsType) { + return `${this.baseURL}/${this.commonParams.owner}/${this.commonParams.repo}/-/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}`; + } + + // GitLab API responses are not cached unlike GitHub, so this method only exists to satisfy the Reporter interface contract + clearCache() { /* eslint-disable-line class-methods-use-this */ + logger.debug('Cache clearing not implemented for GitLab reporter as it is not needed'); + } +} diff --git a/src/reporter/gitlab/index.test.js b/src/reporter/gitlab/index.test.js new file mode 100644 index 00000000..dfc5cd60 --- /dev/null +++ b/src/reporter/gitlab/index.test.js @@ -0,0 +1,527 @@ +import { createRequire } from 'module'; + +import { expect } from 'chai'; +import nock from 'nock'; + +import GitLab from './index.js'; + +const require = createRequire(import.meta.url); + +describe('GitLab', function () { + this.timeout(5000); + + let MANAGED_LABELS; + let gitlab; + const PROJECT_ID = '4'; + + before(() => { + MANAGED_LABELS = require('./labels.json'); + gitlab = new GitLab('owner/repo'); + }); + + describe('#initialize', () => { + const scopes = []; + + before(async () => { + const existingLabels = MANAGED_LABELS.slice(0, -2); + + nock(gitlab.apiBaseURL) + .get(`/projects/${encodeURIComponent('owner/repo')}`) + .reply(200, { id: PROJECT_ID }); + + nock(gitlab.apiBaseURL) + .get(`/projects/${PROJECT_ID}/labels?with_counts=true`) + .reply(200, existingLabels); + + const missingLabels = MANAGED_LABELS.slice(-2); + + for (const label of missingLabels) { + scopes.push(nock(gitlab.apiBaseURL) + .post(`/projects/${PROJECT_ID}/labels`) + .reply(200, { name: label.name })); + } + + await gitlab.initialize(); + }); + + after(nock.cleanAll); + + it('should create missing labels', () => { + scopes.forEach(scope => expect(scope.isDone()).to.be.true); + }); + }); + + describe('#getRepositoryLabels', () => { + let scope; + let result; + const LABELS = [{ name: 'bug' }, { name: 'enhancement' }]; + + before(async () => { + scope = nock(gitlab.apiBaseURL) + .get(`/projects/${PROJECT_ID}/labels?with_counts=true`) + .reply(200, LABELS); + + result = await gitlab.getRepositoryLabels(); + }); + + after(nock.cleanAll); + + it('fetches repository labels', () => { + expect(scope.isDone()).to.be.true; + }); + + it('returns the repository labels', () => { + expect(result).to.deep.equal(LABELS); + }); + }); + + describe('#createLabel', () => { + let scope; + const LABEL = { name: 'new_label', color: 'ffffff' }; + + before(async () => { + scope = nock(gitlab.apiBaseURL) + .post(`/projects/${PROJECT_ID}/labels`, body => body.name === LABEL.name) + .reply(200, LABEL); + + await gitlab.createLabel(LABEL); + }); + + after(nock.cleanAll); + + it('creates the new label', () => { + expect(scope.isDone()).to.be.true; + }); + }); + + describe('#createIssue', () => { + let scope; + let result; + + const ISSUE = { + title: 'New Issue', + description: 'Description of the new issue', + labels: ['bug'], + }; + const CREATED_ISSUE = { + title: 'New Issue', + description: 'Description of the new issue', + labels: ['bug'], + iid: 555, + web_url: 'https://example.com/test/test', + }; + + before(async () => { + scope = nock(gitlab.apiBaseURL) + .post(`/projects/${PROJECT_ID}/issues`) + .reply(200, CREATED_ISSUE); + + result = await gitlab.createIssue(ISSUE); + }); + + after(nock.cleanAll); + + it('creates the new issue', () => { + expect(scope.isDone()).to.be.true; + }); + + it('returns the created issue', () => { + expect(result).to.deep.equal(CREATED_ISSUE); + }); + }); + + describe('#setIssueLabels', () => { + let scope; + const issue = { + iid: 123, + title: 'test issue', + }; + const labels = [ 'bug', 'enhancement' ]; + + const response = { + iid: 123, + labels, + }; + + before(async () => { + scope = nock(gitlab.apiBaseURL) + .put(`/projects/${PROJECT_ID}/issues/${issue.iid}`, { labels }) + .reply(200, response); + + await gitlab.setIssueLabels({ issue, labels }); + }); + + after(nock.cleanAll); + + it('sets labels on the issue', () => { + expect(scope.isDone()).to.be.true; + }); + }); + + describe('#openIssue', () => { + let scope; + const ISSUE = { iid: 123, title: 'issue reopened' }; + const EXPECTED_REQUEST_BODY = { state_event: 'reopen' }; + const response = { iid: 123 }; + + before(async () => { + scope = nock(gitlab.apiBaseURL) + .put(`/projects/${PROJECT_ID}/issues/${ISSUE.iid}`, EXPECTED_REQUEST_BODY) + .reply(200, response); + + await gitlab.openIssue(ISSUE); + }); + + after(nock.cleanAll); + + it('opens the issue', () => { + expect(scope.isDone()).to.be.true; + }); + }); + + describe('#closeIssue', () => { + let scope; + const ISSUE = { iid: 123, title: 'close issue' }; + const EXPECTED_REQUEST_BODY = { state_event: 'close' }; + const response = { iid: 123 }; + + before(async () => { + scope = nock(gitlab.apiBaseURL) + .put(`/projects/${PROJECT_ID}/issues/${ISSUE.iid}`, EXPECTED_REQUEST_BODY) + .reply(200, response); + + await gitlab.closeIssue(ISSUE); + }); + + after(nock.cleanAll); + + it('closes the issue', () => { + expect(scope.isDone()).to.be.true; + }); + }); + + describe('#getIssue', () => { + let scope; + let result; + + const ISSUE = { number: 123, title: 'Test Issue' }; + const ANOTHER_ISSUE = { number: 124, title: 'Test Issue 2' }; + + before(async () => { + scope = nock(gitlab.apiBaseURL) + .get(`/projects/${PROJECT_ID}/issues?search=${encodeURIComponent(ISSUE.title)}&per_page=100`) + .reply(200, [ ISSUE, ANOTHER_ISSUE ]); + + result = await gitlab.getIssue({ title: ISSUE.title }); + }); + + after(nock.cleanAll); + + it('searches for the issue', () => { + expect(scope.isDone()).to.be.true; + }); + + it('returns the expected issue', () => { + expect(result).to.deep.equal(ISSUE); + }); + }); + + describe('#addCommentToIssue', () => { + let scope; + const ISSUE = { iid: 123, title: 'Test Issue' }; + const COMMENT = 'Test comment'; + const response = { iid: 123, id: 23, body: 'Test comment' }; + + before(async () => { + scope = nock(gitlab.apiBaseURL) + .post(`/projects/${PROJECT_ID}/issues/${ISSUE.iid}/notes`, { body: COMMENT }) + .reply(200, response); + + await gitlab.addCommentToIssue({ issue: ISSUE, comment: COMMENT }); + }); + + after(nock.cleanAll); + + it('adds the comment to the issue', () => { + expect(scope.isDone()).to.be.true; + }); + }); + + describe('#closeIssueWithCommentIfExists', () => { + after(nock.cleanAll); + + context('when the issue exists and is open', () => { + const ISSUE = { + iid: 123, + title: 'Open Issue', + state: GitLab.ISSUE_STATE_OPEN, + }; + let addCommentScope; + let closeIssueScope; + const COMMENT = 'Closing comment'; + const responseAddcomment = { iid: 123, id: 23, body: COMMENT }; + const closeissueBody = { state_event: 'close' }; + const responseCloseissue = { iid: 123 }; + + before(async () => { + nock(gitlab.apiBaseURL) + .get(`/projects/${PROJECT_ID}/issues?search=${encodeURIComponent(ISSUE.title)}&per_page=100`) + .reply(200, [ISSUE]); + + addCommentScope = nock(gitlab.apiBaseURL) + .post(`/projects/${PROJECT_ID}/issues/${ISSUE.iid}/notes`, { body: COMMENT }) + .reply(200, responseAddcomment); + + closeIssueScope = nock(gitlab.apiBaseURL) + .put(`/projects/${PROJECT_ID}/issues/${ISSUE.iid}`, closeissueBody) + .reply(200, responseCloseissue); + + await gitlab.closeIssueWithCommentIfExists({ title: ISSUE.title, comment: COMMENT }); + }); + + it('adds comment to the issue', () => { + expect(addCommentScope.isDone()).to.be.true; + }); + + it('closes the issue', () => { + expect(closeIssueScope.isDone()).to.be.true; + }); + }); + + context('when the issue exists and is closed', () => { + const ISSUE = { + number: 123, + title: 'Closed Issue', + state: GitLab.ISSUE_STATE_CLOSED, + }; + let addCommentScope; + let closeIssueScope; + const COMMENT = 'Closing comment'; + const responseAddcomment = { iid: 123, id: 23, body: COMMENT }; + const closeissueBody = { state_event: 'close' }; + const responseCloseissue = { iid: 123 }; + + before(async () => { + nock(gitlab.apiBaseURL) + .get(`/projects/${PROJECT_ID}/issues?search=${encodeURIComponent(ISSUE.title)}&per_page=100`) + .reply(200, []); + + addCommentScope = nock(gitlab.apiBaseURL) + .post(`/projects/${PROJECT_ID}/issues/${ISSUE.iid}/notes`, { body: COMMENT }) + .reply(200, responseAddcomment); + + closeIssueScope = nock(gitlab.apiBaseURL) + .put(`/projects/${PROJECT_ID}/issues/${ISSUE.iid}`, closeissueBody) + .reply(200, responseCloseissue); + + await gitlab.closeIssueWithCommentIfExists({ title: ISSUE.title, comment: COMMENT }); + }); + + it('does not add comment', () => { + expect(addCommentScope.isDone()).to.be.false; + }); + + it('does not attempt to close the issue', () => { + expect(closeIssueScope.isDone()).to.be.false; + }); + }); + + context('when the issue does not exist', () => { + let addCommentScope; + let closeIssueScope; + const COMMENT = 'Closing comment'; + const TITLE = 'Non-existent Issue'; + const responseAddcomment = { iid: 123, id: 23, body: COMMENT }; + const closeissueBody = { state_event: 'close' }; + const responseCloseissue = { iid: 123 }; + + before(async () => { + nock(gitlab.apiBaseURL) + .get(`/projects/${PROJECT_ID}/issues?search=${encodeURIComponent(TITLE)}&per_page=100`) + .reply(200, []); + + addCommentScope = nock(gitlab.apiBaseURL) + .post(/\/projects\/\d+\/issues\/\d+\/notes/, { body: COMMENT }) + .reply(200, responseAddcomment); + + closeIssueScope = nock(gitlab.apiBaseURL) + .put(/\/projects\/\d+\/issues\/\d+/, closeissueBody) + .reply(200, responseCloseissue); + + await gitlab.closeIssueWithCommentIfExists({ title: TITLE, comment: COMMENT }); + }); + + it('does not attempt to add comment', () => { + expect(addCommentScope.isDone()).to.be.false; + }); + + it('does not attempt to close the issue', () => { + expect(closeIssueScope.isDone()).to.be.false; + }); + }); + }); + + describe('#createOrUpdateIssue', () => { + before(async () => { + nock(gitlab.apiBaseURL) + .get(`/projects/${encodeURIComponent('owner/repo')}`) + .reply(200, { id: 4 }); + + nock(gitlab.apiBaseURL) + .get(`/projects/${PROJECT_ID}/labels?with_counts=true`) + .reply(200, MANAGED_LABELS); + + await gitlab.initialize(); + }); + + context('when the issue does not exist', () => { + let createIssueScope; + const ISSUE_TO_CREATE = { + title: 'New Issue', + description: 'Description of the new issue', + label: 'bug', + }; + + before(async () => { + nock(gitlab.apiBaseURL) + .get(`/projects/${PROJECT_ID}/issues?search=${encodeURIComponent(ISSUE_TO_CREATE.title)}&per_page=100`) + .reply(200, []); // Simulate that there is no issues on the repository + + createIssueScope = nock(gitlab.apiBaseURL) + .post( + `/projects/${PROJECT_ID}/issues`, + { + title: ISSUE_TO_CREATE.title, + description: ISSUE_TO_CREATE.description, + labels: [ISSUE_TO_CREATE.label], + }, + ) + .reply(200, { iid: 123, web_url: 'https://example.com/test/test' }); + + await gitlab.createOrUpdateIssue(ISSUE_TO_CREATE); + }); + + it('creates the issue', () => { + expect(createIssueScope.isDone()).to.be.true; + }); + }); + + context('when the issue already exists', () => { + const ISSUE = { + title: 'Existing Issue', + description: 'New comment', + label: 'location', + }; + + context('when issue is closed', () => { + let setIssueLabelsScope; + let addCommentScope; + let openIssueScope; + + const GITLAB_RESPONSE_FOR_EXISTING_ISSUE = { + iid: 123, + title: ISSUE.title, + description: ISSUE.description, + labels: [{ name: 'selectors' }], + state: GitLab.ISSUE_STATE_CLOSED, + }; + + const EXPECTED_REQUEST_BODY = { state_event: 'reopen' }; + const responseIssuereopened = { iid: 123 }; + const responseSetLabels = { + iid: 123, + labels: ['location'], + }; + const responseAddcomment = { iid: 123, id: 23, body: ISSUE.description }; + const { iid } = GITLAB_RESPONSE_FOR_EXISTING_ISSUE; + + before(async () => { + nock(gitlab.apiBaseURL) + .get(`/projects/${PROJECT_ID}/issues?search=${encodeURIComponent(ISSUE.title)}&per_page=100`) + .reply(200, [GITLAB_RESPONSE_FOR_EXISTING_ISSUE]); + + openIssueScope = nock(gitlab.apiBaseURL) + .put(`/projects/${PROJECT_ID}/issues/${iid}`, EXPECTED_REQUEST_BODY) + .reply(200, responseIssuereopened); + + setIssueLabelsScope = nock(gitlab.apiBaseURL) + .put(`/projects/${PROJECT_ID}/issues/${iid}`, { labels: ['location'] }) + .reply(200, responseSetLabels); + + addCommentScope = nock(gitlab.apiBaseURL) + .post(`/projects/${PROJECT_ID}/issues/${iid}/notes`, { body: ISSUE.description }) + .reply(200, responseAddcomment); + + await gitlab.createOrUpdateIssue(ISSUE); + }); + + it('reopens the issue', () => { + expect(openIssueScope.isDone()).to.be.true; + }); + + it("updates the issue's label", () => { + expect(setIssueLabelsScope.isDone()).to.be.true; + }); + + it('adds comment to the issue', () => { + expect(addCommentScope.isDone()).to.be.true; + }); + }); + + context('when issue is already opened', () => { + let setIssueLabelsScope; + let addCommentScope; + let openIssueScope; + + const GITLAB_RESPONSE_FOR_EXISTING_ISSUE = { + number: 123, + title: ISSUE.title, + description: ISSUE.description, + labels: [{ name: 'selectors' }], + state: GitLab.ISSUE_STATE_OPEN, + }; + + const EXPECTED_REQUEST_BODY = { state_event: 'reopen' }; + const responseIssuereopened = { iid: 123 }; + const responseSetLabels = { + iid: 123, + labels: ['location'], + }; + const responseAddcomment = { iid: 123, id: 23, body: ISSUE.description }; + const { iid } = GITLAB_RESPONSE_FOR_EXISTING_ISSUE; + + before(async () => { + nock(gitlab.apiBaseURL) + .get(`/projects/${PROJECT_ID}/issues?search=${encodeURIComponent(ISSUE.title)}&per_page=100`) + .reply(200, [GITLAB_RESPONSE_FOR_EXISTING_ISSUE]); + + openIssueScope = nock(gitlab.apiBaseURL) + .put(`/projects/${PROJECT_ID}/issues/${iid}`, EXPECTED_REQUEST_BODY) + .reply(200, responseIssuereopened); + + setIssueLabelsScope = nock(gitlab.apiBaseURL) + .put(`/projects/${PROJECT_ID}/issues/${iid}`, { labels: ['location'] }) + .reply(200, responseSetLabels); + + addCommentScope = nock(gitlab.apiBaseURL) + .post(`/projects/${PROJECT_ID}/issues/${iid}/notes`, { body: ISSUE.description }) + .reply(200, responseAddcomment); + + await gitlab.createOrUpdateIssue(ISSUE); + }); + + it('does not change the issue state', () => { + expect(openIssueScope.isDone()).to.be.false; + }); + + it("updates the issue's label", () => { + expect(setIssueLabelsScope.isDone()).to.be.true; + }); + + it('adds comment to the issue', () => { + expect(addCommentScope.isDone()).to.be.true; + }); + }); + }); + }); +}); diff --git a/src/reporter/gitlab/labels.json b/src/reporter/gitlab/labels.json new file mode 100644 index 00000000..8d73f4e8 --- /dev/null +++ b/src/reporter/gitlab/labels.json @@ -0,0 +1,77 @@ +[ + { + "name": "403", + "color": "#0b08a0", + "description": "Fetching fails with a 403 (forbidden) HTTP code" + }, + { + "name": "429", + "color": "#0b08a0", + "description": "Fetching fails with a 429 (too many requests) HTTP code" + }, + { + "name": "500", + "color": "#0b08a0", + "description": "Fetching fails with a 500 (internal server error) HTTP code" + }, + { + "name": "502", + "color": "#0b08a0", + "description": "Fetching fails with a 502 (bad gateway) HTTP code" + }, + { + "name": "503", + "color": "#0b08a0", + "description": "Fetching fails with a 503 (service unavailable) HTTP code" + }, + { + "name": "certificate expired", + "color": "#0b08a0", + "description": "Fetching fails because the domain SSL certificate has expired" + }, + { + "name": "EAI_AGAIN", + "color": "#0b08a0", + "description": "Fetching fails because the domain fails to resolve on DNS" + }, + { + "name": "ENOTFOUND", + "color": "#0b08a0", + "description": "Fetching fails because the domain fails to resolve on DNS" + }, + { + "name": "empty response", + "color": "#0b08a0", + "description": "Fetching fails with a “response is empty” error" + }, + { + "name": "first certificate", + "color": "#0b08a0", + "description": "Fetching fails with an “unable to verify the first certificate” error" + }, + { + "name": "redirects", + "color": "#0b08a0", + "description": "Fetching fails with a “too many redirects” error" + }, + { + "name": "timeout", + "color": "#0b08a0", + "description": "Fetching fails with a timeout error" + }, + { + "name": "to clarify", + "color": "#0496ff", + "description": "Default failure label" + }, + { + "name": "selectors", + "color": "#FBCA04", + "description": "Extraction selectors are outdated" + }, + { + "name": "location", + "color": "#FBCA04", + "description": "Fetch location is outdated" + } +] diff --git a/src/reporter/gitlab/labels.test.js b/src/reporter/gitlab/labels.test.js new file mode 100644 index 00000000..1c50a892 --- /dev/null +++ b/src/reporter/gitlab/labels.test.js @@ -0,0 +1,30 @@ +import { createRequire } from 'module'; + +import chai from 'chai'; + +import { MANAGED_BY_OTA_MARKER } from './index.js'; + +const require = createRequire(import.meta.url); + +const { expect } = chai; +const labels = require('./labels.json'); + +const GITLAB_LABEL_DESCRIPTION_MAX_LENGTH = 255; + +describe('Reporter GitLab labels', () => { + labels.forEach(label => { + describe(`"${label.name}"`, () => { + it('complies with the GitLab character limit for descriptions', () => { + const descriptionLength = label.description.length + MANAGED_BY_OTA_MARKER.length; + + expect(descriptionLength).to.be.lessThan(GITLAB_LABEL_DESCRIPTION_MAX_LENGTH); + }); + + it('complies with the GitLab constraints for color', () => { + const validHexColorRegex = /^#[0-9a-fA-F]{6}$/; + + expect(validHexColorRegex.test(label.color)).to.be.true; + }); + }); + }); +}); diff --git a/src/reporter/index.js b/src/reporter/index.js index 23b678de..d3014b08 100644 --- a/src/reporter/index.js +++ b/src/reporter/index.js @@ -1,8 +1,9 @@ import mime from 'mime'; import { toISODateWithoutMilliseconds } from '../archivist/utils/date.js'; +import logger from '../logger/index.js'; -import GitHub from './github.js'; +import { createReporter } from './factory.js'; const CONTRIBUTION_TOOL_URL = 'https://contribute.opentermsarchive.org/en/service'; const DOC_URL = 'https://docs.opentermsarchive.org'; @@ -33,28 +34,65 @@ function getLabelNameFromError(error) { // In the following class, it is assumed that each issue is managed using its title as a unique identifier export default class Reporter { constructor(config) { - const { repositories } = config.githubIssues; + const normalizedConfig = Reporter.normalizeConfig(config); - for (const repositoryType of Object.keys(repositories)) { - if (!repositories[repositoryType].includes('/') || repositories[repositoryType].includes('https://')) { - throw new Error(`Configuration entry "reporter.githubIssues.repositories.${repositoryType}" is expected to be a string in the format /, but received: "${repositories[repositoryType]}"`); - } + Reporter.validateConfiguration(normalizedConfig.repositories); + + this.reporter = createReporter(normalizedConfig); + this.repositories = normalizedConfig.repositories; + } + + /** + * Support for legacy config format where reporter configuration was nested under `githubIssues` + * Example: + * + * ```json + * { + * "githubIssues": { + * "repositories": { + * "declarations": "OpenTermsArchive/sandbox-declarations" + * } + * } + * } + * ``` + * + * @deprecated + */ + static normalizeConfig(config) { + if (config.githubIssues) { + logger.warn('The "reporter.githubIssues" key is deprecated; please see configuration documentation for the new format: https://docs.opentermsarchive.org/#configuring'); + + return { + type: 'github', + repositories: config.githubIssues.repositories, + }; + } + + return config; + } + + static validateConfiguration(repositories) { + if (!repositories?.declarations) { + throw new Error('Required configuration key "reporter.repositories.declarations" was not found; issues on the declarations repository cannot be created'); } - this.github = new GitHub(repositories.declarations); - this.repositories = repositories; + for (const [ type, repo ] of Object.entries(repositories)) { + if (!repo.includes('/') || repo.includes('https://')) { + throw new Error(`Configuration entry "reporter.repositories.${type}" is expected to be a string in the format /, but received: "${repo}"`); + } + } } initialize() { - return this.github.initialize(); + return this.reporter.initialize(); } onTrackingStarted() { - return this.github.clearCache(); + return this.reporter.clearCache(); } async onVersionRecorded(version) { - await this.github.closeIssueWithCommentIfExists({ + await this.reporter.closeIssueWithCommentIfExists({ title: Reporter.generateTitleID(version.serviceId, version.termsType), comment: `### Tracking resumed @@ -63,7 +101,7 @@ A new version has been recorded.`, } async onVersionNotChanged(version) { - await this.github.closeIssueWithCommentIfExists({ + await this.reporter.closeIssueWithCommentIfExists({ title: Reporter.generateTitleID(version.serviceId, version.termsType), comment: `### Tracking resumed @@ -76,7 +114,7 @@ No changes were found in the last run, so no new version has been recorded.`, } async onInaccessibleContent(error, terms) { - await this.github.createOrUpdateIssue({ + await this.reporter.createOrUpdateIssue({ title: Reporter.generateTitleID(terms.service.id, terms.type), description: this.generateDescription({ error, terms }), label: getLabelNameFromError(error), @@ -97,9 +135,9 @@ No changes were found in the last run, so no new version has been recorded.`, }); const contributionToolUrl = `${CONTRIBUTION_TOOL_URL}?${contributionToolParams}`; - const latestDeclarationLink = `[Latest declaration](https://github.com/${this.repositories.declarations}/blob/main/declarations/${encodeURIComponent(terms.service.name)}.json)`; - const latestVersionLink = `[Latest version](https://github.com/${this.repositories.versions}/blob/main/${encodeURIComponent(terms.service.name)}/${encodeURIComponent(terms.type)}.md)`; - const snapshotsBaseUrl = `https://github.com/${this.repositories.snapshots}/blob/main/${encodeURIComponent(terms.service.name)}/${encodeURIComponent(terms.type)}`; + const latestDeclarationLink = `[Latest declaration](${this.reporter.generateDeclarationURL(terms.service.name)})`; + const latestVersionLink = `[Latest version](${this.reporter.generateVersionURL(terms.service.name, terms.type)})`; + const snapshotsBaseUrl = this.reporter.generateSnapshotsBaseUrl(terms.service.name, terms.type); const latestSnapshotsLink = terms.hasMultipleSourceDocuments ? `Latest snapshots:\n - ${terms.sourceDocuments.map(sourceDocument => `[${sourceDocument.id}](${snapshotsBaseUrl}.%20#${sourceDocument.id}.${mime.getExtension(sourceDocument.mimeType)})`).join('\n - ')}` : `[Latest snapshot](${snapshotsBaseUrl}.${mime.getExtension(terms.sourceDocuments[0].mimeType)})`; diff --git a/src/reporter/index.test.js b/src/reporter/index.test.js new file mode 100644 index 00000000..14fb03f5 --- /dev/null +++ b/src/reporter/index.test.js @@ -0,0 +1,63 @@ +import { expect } from 'chai'; + +import Reporter from './index.js'; + +describe('Reporter', () => { + describe('#normalizeConfig', () => { + context('with current config format', () => { + it('returns the config as is', () => { + const config = { repositories: { declarations: 'owner/repo' } }; + const normalizedConfig = Reporter.normalizeConfig(config); + + expect(normalizedConfig).to.deep.equal(config); + }); + }); + + context('with old config format where githubIssues is nested under reporter', () => { + it('returns a normalized config', () => { + const config = { githubIssues: { repositories: { declarations: 'owner/repo' } } }; + const expectedConfig = { + type: 'github', + repositories: { declarations: 'owner/repo' }, + }; + const normalizedConfig = Reporter.normalizeConfig(config); + + expect(normalizedConfig).to.deep.equal(expectedConfig); + }); + }); + }); + + describe('#validateConfiguration', () => { + context('with valid configuration', () => { + it('does not throw an error', () => { + const repositories = { declarations: 'owner/repo' }; + + expect(() => { + Reporter.validateConfiguration(repositories); + }).not.to.throw(); + }); + }); + + context('with invalid configuration', () => { + context('when declarations key is missing', () => { + it('throws an error', () => { + const repositories = {}; + + expect(() => { + Reporter.validateConfiguration(repositories); + }).to.throw().and.have.property('message').that.match(/Required configuration key.*was not found/); + }); + }); + + context('when repository format is incorrect', () => { + it('throws an error', () => { + const repositories = { declarations: 'invalidFormat' }; + + expect(() => { + Reporter.validateConfiguration(repositories); + }).to.throw('Configuration entry "reporter.repositories.declarations" is expected to be a string in the format /, but received: "invalidFormat"'); + }); + }); + }); + }); +});