@opentermsarchive/engine 16.1.0 → 16.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "16.1.0",
3
+ "version": "16.2.0",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
package/src/git/index.js CHANGED
@@ -5,6 +5,7 @@ import simpleGit from 'simple-git';
5
5
 
6
6
  import { GitObjectNotFoundError } from './errors.js';
7
7
  import { parseTrailers, formatTrailers } from './trailers.js';
8
+ import { acquireWriterLock, WriterLockHeldError } from './writerLock.js';
8
9
 
9
10
  export { GitObjectNotFoundError } from './errors.js';
10
11
 
@@ -12,6 +13,8 @@ process.env.LC_ALL = 'en_GB'; // Ensure git messages will be in English as some
12
13
 
13
14
  const fs = fsApi.promises;
14
15
 
16
+ const WRITER_LOCK_FILE_NAME = 'ota-writer.lock';
17
+
15
18
  const OBJECT_NOT_FOUND_MESSAGES = /bad object|not a tree|invalid object name|unknown revision|does not exist|exists on disk, but not in/i;
16
19
 
17
20
  export default class Git {
@@ -51,6 +54,7 @@ export default class Git {
51
54
 
52
55
  this.#connect();
53
56
  await this.git.init();
57
+ await this.#acquireWriterLock();
54
58
 
55
59
  const configFile = path.resolve(this.path, '.git', 'config'); // Anchored to an absolute path: git resolves a relative `--file` argument against its own cwd (the repository), not against process.cwd, so a relative repository path would silently point the write at a nonexistent nested location
56
60
 
@@ -69,6 +73,20 @@ export default class Git {
69
73
  .raw([ 'config', '--file', configFile, 'gc.writeCommitGraph', 'false' ]); // Prevent automatic `git gc` from also writing the commit-graph: the engine writes it explicitly (see `writeCommitGraph`/`updateCommitGraph`), and a concurrent gc write races those, which can leave a stale `commit-graph.lock` and make subsequent operations fail
70
74
  }
71
75
 
76
+ async #acquireWriterLock() { // Held until the process exits, so that no other process writes to the repository meanwhile: concurrent writers race the working tree and the index, and the clean up of one discards the uncommitted changes of the other
77
+ const lockFilePath = path.join(this.path, '.git', WRITER_LOCK_FILE_NAME);
78
+
79
+ try {
80
+ await acquireWriterLock(lockFilePath); // Acquired again silently by the same process, as repositories are initialized again at each tracking run
81
+ } catch (error) {
82
+ if (!(error instanceof WriterLockHeldError)) {
83
+ throw error;
84
+ }
85
+
86
+ throw new Error(`Repository ${this.path} is already being written by the process ${error.holderPid}; stop it before starting another writer, such as the tracker or the technical upgrades, or remove ${lockFilePath} if that process is not an Open Terms Archive writer`);
87
+ }
88
+ }
89
+
72
90
  open() {
73
91
  if (!fsApi.existsSync(path.join(this.path, '.git'))) {
74
92
  throw new Error(`Repository ${this.path} does not exist, it has to be created by a writer such as the tracker first`); // Without this check, git would silently walk up to an enclosing repository, such as the collection one
@@ -183,6 +201,7 @@ export default class Git {
183
201
  }
184
202
 
185
203
  async cleanUp() {
204
+ await fs.rm(path.join(this.path, '.git', 'index.lock'), { force: true }); // Remove a leftover index lock from a Git operation that was killed mid-write, which would otherwise make every subsequent reset, add and commit fail. Safe as the writer lock guarantees that no other process writes to the repository, and readers never lock the index
186
205
  await fs.rm(path.join(this.path, '.git', 'objects', 'info', 'commit-graph.lock'), { force: true }); // Remove a leftover commit-graph lock from a previous `commit-graph write` that was killed mid-write (e.g. the process was terminated during a deploy or restart). The commit-graph is a disposable cache rebuilt by `writeCommitGraph`, so clearing a stale lock is safe and prevents every subsequent run from failing.
187
206
  await this.git.reset('hard');
188
207
 
@@ -1,13 +1,17 @@
1
+ import { spawn } from 'child_process';
1
2
  import fs from 'fs/promises';
2
3
  import os from 'os';
3
4
  import path from 'path';
4
5
  import { fileURLToPath } from 'url';
5
6
 
6
- import { expect } from 'chai';
7
+ import { expect, use } from 'chai';
8
+ import chaiAsPromised from 'chai-as-promised';
7
9
  import config from 'config';
8
10
 
9
11
  import Git, { GitObjectNotFoundError } from './index.js';
10
12
 
13
+ use(chaiAsPromised);
14
+
11
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
16
  const RECORDER_PATH = path.resolve(__dirname, '../../', config.get('@opentermsarchive/engine.recorder.versions.storage.git.path'));
13
17
 
@@ -28,6 +32,51 @@ describe('Git', () => {
28
32
  return subject.initialize();
29
33
  });
30
34
 
35
+ describe('#initialize', () => {
36
+ const AUTHOR = { name: 'Writer lock tester', email: 'writer-lock@example.com' };
37
+ let repositoryPath;
38
+ let lockFilePath;
39
+
40
+ beforeEach(async () => {
41
+ repositoryPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ota-writer-lock-'));
42
+ lockFilePath = path.join(repositoryPath, '.git', 'ota-writer.lock');
43
+ });
44
+
45
+ afterEach(() => fs.rm(repositoryPath, { recursive: true, force: true }));
46
+
47
+ it('locks the repository with the PID of the current process', async () => {
48
+ await new Git({ path: repositoryPath, author: AUTHOR }).initialize();
49
+
50
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
51
+ });
52
+
53
+ it('can be initialized again by the same process', async () => {
54
+ await new Git({ path: repositoryPath, author: AUTHOR }).initialize();
55
+
56
+ await expect(new Git({ path: repositoryPath, author: AUTHOR }).initialize()).to.be.fulfilled;
57
+ });
58
+
59
+ context('when another running process holds the lock', () => {
60
+ let otherProcess;
61
+
62
+ beforeEach(async () => {
63
+ otherProcess = spawn(process.execPath, [ '-e', 'setInterval(() => {}, 1000)' ]);
64
+ await fs.mkdir(path.dirname(lockFilePath), { recursive: true });
65
+ await fs.writeFile(lockFilePath, String(otherProcess.pid));
66
+ });
67
+
68
+ afterEach(() => otherProcess.kill());
69
+
70
+ it('rejects with an error naming the holder', async () => {
71
+ await expect(new Git({ path: repositoryPath, author: AUTHOR }).initialize()).to.be.rejectedWith(`already being written by the process ${otherProcess.pid}`);
72
+ });
73
+
74
+ it('names the lock file in the error', async () => {
75
+ await expect(new Git({ path: repositoryPath, author: AUTHOR }).initialize()).to.be.rejectedWith(lockFilePath);
76
+ });
77
+ });
78
+ });
79
+
31
80
  describe('#commit', () => {
32
81
  const expectedFilePath = `${RECORDER_PATH}/test.md`;
33
82
 
@@ -136,6 +185,36 @@ describe('Git', () => {
136
185
  expect(infoDirectoryContent).to.not.include('commit-graph.lock');
137
186
  });
138
187
  });
188
+ context('when an index lock has been left behind by an interrupted process', () => {
189
+ const indexLockFilePath = path.join(RECORDER_PATH, '.git', 'index.lock');
190
+
191
+ before(async () => {
192
+ const filePath = `${RECORDER_PATH}/file-to-clean.md`;
193
+
194
+ await fs.writeFile(filePath, DEFAULT_CONTENT);
195
+ await subject.add(filePath);
196
+ await subject.commit({ filePath, message: DEFAULT_COMMIT_MESSAGE });
197
+
198
+ await fs.writeFile(indexLockFilePath, '');
199
+
200
+ await subject.cleanUp();
201
+ });
202
+
203
+ after(() => subject.destroyHistory());
204
+
205
+ it('removes the stale index lock', async () => {
206
+ await expect(fs.access(indexLockFilePath)).to.be.rejected;
207
+ });
208
+
209
+ it('leaves the repository writable', async () => {
210
+ const filePath = `${RECORDER_PATH}/file-after-clean.md`;
211
+
212
+ await fs.writeFile(filePath, DEFAULT_CONTENT);
213
+ await subject.add(filePath);
214
+
215
+ expect(await subject.commit({ filePath, message: DEFAULT_COMMIT_MESSAGE })).to.be.a('string');
216
+ });
217
+ });
139
218
  });
140
219
 
141
220
  describe('.getHeadSha', () => {
@@ -0,0 +1,101 @@
1
+ import fsApi from 'fs';
2
+ import os from 'os';
3
+
4
+ const fs = fsApi.promises;
5
+
6
+ const heldLocks = new Set();
7
+
8
+ process.on('exit', () => heldLocks.forEach(release)); // A process killed without exiting leaves its lock behind, which the next taker detects as stale
9
+
10
+ export class WriterLockHeldError extends Error {
11
+ constructor(lockFilePath, holderPid) {
12
+ super(`Lock ${lockFilePath} is held by the process ${holderPid}`);
13
+ this.name = 'WriterLockHeldError';
14
+ this.lockFilePath = lockFilePath;
15
+ this.holderPid = holderPid;
16
+ }
17
+ }
18
+
19
+ export async function acquireWriterLock(lockFilePath) { // Held until the process exits; a process already holding the lock acquires it again silently
20
+ try {
21
+ await fs.writeFile(lockFilePath, String(process.pid), { flag: 'wx' }); // Exclusive creation, so that only one of two processes starting at the same time gets the lock
22
+ } catch (error) {
23
+ if (error.code !== 'EEXIST') {
24
+ throw error;
25
+ }
26
+
27
+ const { holderPid, writtenAt } = await read(lockFilePath);
28
+
29
+ if (holderPid === process.pid) { // Left by a previous process that had the same PID, such as the same container restarted after a kill
30
+ heldLocks.add(lockFilePath);
31
+
32
+ return;
33
+ }
34
+
35
+ if (isAlive(holderPid) && writtenAt > Date.now() - os.uptime() * 1000) { // A lock written before the last boot is stale, even if its PID has since been reused by another process
36
+ throw new WriterLockHeldError(lockFilePath, holderPid);
37
+ }
38
+
39
+ await takeOver(lockFilePath);
40
+
41
+ return acquireWriterLock(lockFilePath);
42
+ }
43
+
44
+ heldLocks.add(lockFilePath);
45
+ }
46
+
47
+ async function read(lockFilePath) {
48
+ try {
49
+ const [ content, { mtimeMs }] = await Promise.all([ fs.readFile(lockFilePath, 'utf8'), fs.stat(lockFilePath) ]);
50
+
51
+ return { holderPid: Number(content), writtenAt: mtimeMs };
52
+ } catch (error) {
53
+ if (error.code !== 'ENOENT') {
54
+ throw error;
55
+ }
56
+
57
+ return { holderPid: null, writtenAt: 0 }; // The holder released the lock in the meantime, so it is taken over as a stale one
58
+ }
59
+ }
60
+
61
+ async function takeOver(lockFilePath) {
62
+ const staleLockFilePath = `${lockFilePath}.${process.pid}`;
63
+
64
+ try {
65
+ await fs.rename(lockFilePath, staleLockFilePath); // Atomic, so that only one of two processes taking over the same stale lock at the same time succeeds; the other finds the fresh lock of the winner when it retries
66
+ } catch (error) {
67
+ if (error.code !== 'ENOENT') {
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ await fs.rm(staleLockFilePath, { force: true });
73
+ }
74
+
75
+ function release(lockFilePath) { // Synchronous, as asynchronous work is not run on exit
76
+ let holder;
77
+
78
+ try {
79
+ holder = fsApi.readFileSync(lockFilePath, 'utf8');
80
+ } catch {
81
+ return; // Already released, for example along with the directory that held it
82
+ }
83
+
84
+ if (holder === String(process.pid)) { // Leave the lock to a process that took it over, which happens when the clock was set forward after this process wrote it
85
+ fsApi.rmSync(lockFilePath, { force: true });
86
+ }
87
+ }
88
+
89
+ function isAlive(pid) {
90
+ if (!Number.isInteger(pid) || pid <= 0) { // An empty or corrupted lock file, left by a process killed while creating it
91
+ return false;
92
+ }
93
+
94
+ try {
95
+ process.kill(pid, 0); // Signal 0 only checks that the process exists
96
+
97
+ return true;
98
+ } catch (error) {
99
+ return error.code === 'EPERM'; // The process exists but belongs to another user
100
+ }
101
+ }
@@ -0,0 +1,148 @@
1
+ import { execFile, spawn } from 'child_process';
2
+ import fs from 'fs/promises';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { fileURLToPath, pathToFileURL } from 'url';
6
+ import { promisify } from 'util';
7
+
8
+ import { expect, use } from 'chai';
9
+ import chaiAsPromised from 'chai-as-promised';
10
+
11
+ import { acquireWriterLock, WriterLockHeldError } from './writerLock.js';
12
+
13
+ use(chaiAsPromised);
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+
17
+ describe('WriterLock', () => {
18
+ describe('#acquireWriterLock', () => {
19
+ let directory;
20
+ let lockFilePath;
21
+
22
+ beforeEach(async () => {
23
+ directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ota-writer-lock-'));
24
+ lockFilePath = path.join(directory, 'writer.lock');
25
+ });
26
+
27
+ afterEach(() => fs.rm(directory, { recursive: true, force: true }));
28
+
29
+ async function writeLock(content, { date } = {}) {
30
+ await fs.writeFile(lockFilePath, content);
31
+
32
+ if (date) {
33
+ await fs.utimes(lockFilePath, date, date);
34
+ }
35
+ }
36
+
37
+ it('writes the PID of the current process in the lock file', async () => {
38
+ await acquireWriterLock(lockFilePath);
39
+
40
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
41
+ });
42
+
43
+ it('can be acquired again by the same process', async () => {
44
+ await acquireWriterLock(lockFilePath);
45
+
46
+ await expect(acquireWriterLock(lockFilePath)).to.be.fulfilled;
47
+ });
48
+
49
+ context('when another running process holds the lock', () => {
50
+ let otherProcess;
51
+
52
+ beforeEach(async () => {
53
+ otherProcess = spawn(process.execPath, [ '-e', 'setInterval(() => {}, 1000)' ]);
54
+ await writeLock(String(otherProcess.pid));
55
+ });
56
+
57
+ afterEach(() => otherProcess.kill());
58
+
59
+ it('rejects with an error naming the holder and the lock file', async () => {
60
+ const error = await acquireWriterLock(lockFilePath).catch(error => error);
61
+
62
+ expect(error).to.be.an.instanceOf(WriterLockHeldError);
63
+ expect(error.holderPid).to.equal(otherProcess.pid);
64
+ expect(error.lockFilePath).to.equal(lockFilePath);
65
+ });
66
+
67
+ it('leaves the lock to its holder', async () => {
68
+ await acquireWriterLock(lockFilePath).catch(() => {});
69
+
70
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(otherProcess.pid));
71
+ });
72
+
73
+ context('when the lock was written before the last boot', () => {
74
+ beforeEach(() => writeLock(String(otherProcess.pid), { date: new Date(Date.now() - (os.uptime() + 60) * 1000) }));
75
+
76
+ it('takes over the lock', async () => {
77
+ await acquireWriterLock(lockFilePath);
78
+
79
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
80
+ });
81
+ });
82
+ });
83
+
84
+ context('when the lock was left by a process that no longer runs', () => {
85
+ beforeEach(async () => {
86
+ const { pid } = await new Promise(resolve => {
87
+ const exitedProcess = spawn(process.execPath, [ '-e', '' ]);
88
+
89
+ exitedProcess.on('exit', () => resolve(exitedProcess));
90
+ });
91
+
92
+ await writeLock(String(pid));
93
+ });
94
+
95
+ it('takes over the lock', async () => {
96
+ await acquireWriterLock(lockFilePath);
97
+
98
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
99
+ });
100
+
101
+ it('leaves no copy of the stale lock behind', async () => {
102
+ await acquireWriterLock(lockFilePath);
103
+
104
+ expect(await fs.readdir(directory)).to.deep.equal(['writer.lock']);
105
+ });
106
+ });
107
+
108
+ context('when the lock file is empty', () => {
109
+ beforeEach(() => writeLock(''));
110
+
111
+ it('takes over the lock', async () => {
112
+ await acquireWriterLock(lockFilePath);
113
+
114
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
115
+ });
116
+ });
117
+
118
+ context('when the process exits', function () {
119
+ this.timeout(10000); // Starts a separate Node.js process
120
+
121
+ function runInSeparateProcess(script) {
122
+ return promisify(execFile)(process.execPath, [ '--input-type=module', '-e', `import { acquireWriterLock } from ${JSON.stringify(pathToFileURL(path.join(__dirname, 'writerLock.js')).href)}; ${script}` ]); // A file URL rather than a path: on Windows, an absolute path is parsed by the ESM loader as a URL with the drive letter as scheme, which it refuses
123
+ }
124
+
125
+ it('releases the lock', async () => {
126
+ await runInSeparateProcess(`await acquireWriterLock(${JSON.stringify(lockFilePath)});`);
127
+
128
+ await expect(fs.access(lockFilePath)).to.be.rejected;
129
+ });
130
+
131
+ context('when the lock was left by a previous process with the same PID', () => {
132
+ it('releases the lock', async () => {
133
+ await runInSeparateProcess(`import fs from 'fs/promises'; await fs.writeFile(${JSON.stringify(lockFilePath)}, String(process.pid)); await acquireWriterLock(${JSON.stringify(lockFilePath)});`);
134
+
135
+ await expect(fs.access(lockFilePath)).to.be.rejected;
136
+ });
137
+ });
138
+
139
+ context('when another process took the lock over meanwhile', () => {
140
+ it('leaves the lock to that process', async () => {
141
+ await runInSeparateProcess(`import fs from 'fs/promises'; await acquireWriterLock(${JSON.stringify(lockFilePath)}); await fs.writeFile(${JSON.stringify(lockFilePath)}, '${process.pid}');`);
142
+
143
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
144
+ });
145
+ });
146
+ });
147
+ });
148
+ });
package/src/index.js CHANGED
@@ -85,7 +85,7 @@ export default async function track({ services, types, schedule }) {
85
85
  }
86
86
 
87
87
  export async function applyTechnicalUpgrades({ services, types }) {
88
- const { archivist, services: filteredServices } = await initialize(services); // Without tracking-results: technical upgrades never record any, and initializing the module would finalize as crashed the run that a tracking process may have in progress
88
+ const { archivist, services: filteredServices } = await initialize(services); // Without tracking-results, as technical upgrades never record any
89
89
 
90
90
  await archivist.applyTechnicalUpgrades({ services: filteredServices, types });
91
91
  }
@@ -4,9 +4,9 @@ import GitLab from './gitlab/index.js';
4
4
  export function createReporter(config) {
5
5
  switch (config.type) {
6
6
  case 'github':
7
- return new GitHub(config.repositories.declarations);
7
+ return new GitHub(config.repositories);
8
8
  case 'gitlab':
9
- return new GitLab(config.repositories.declarations, config.baseURL, config.apiBaseURL);
9
+ return new GitLab(config.repositories, config.baseURL, config.apiBaseURL);
10
10
  default:
11
11
  throw new Error(`Unsupported reporter type: ${config.type}`);
12
12
  }
@@ -13,7 +13,7 @@ export default class GitHub {
13
13
  static ISSUE_STATE_ALL = 'all';
14
14
  static MAX_LABEL_DESCRIPTION_LENGTH = 100;
15
15
 
16
- constructor(repository) {
16
+ constructor(repositories) {
17
17
  const { version } = require('../../../package.json');
18
18
 
19
19
  this.octokit = new Octokit({
@@ -25,7 +25,9 @@ export default class GitHub {
25
25
  },
26
26
  });
27
27
 
28
- const [ owner, repo ] = repository.split('/');
28
+ this.repositories = repositories; // Each generated link points into its own repository, while the issues live in the declarations one
29
+
30
+ const [ owner, repo ] = repositories.declarations.split('/');
29
31
 
30
32
  this.commonParams = { owner, repo };
31
33
 
@@ -272,14 +274,14 @@ export default class GitHub {
272
274
  }
273
275
 
274
276
  generateDeclarationURL(serviceName) {
275
- return `https://github.com/${this.commonParams.owner}/${this.commonParams.repo}/blob/main/declarations/${encodeURIComponent(serviceName)}.json`;
277
+ return `https://github.com/${this.repositories.declarations}/blob/main/declarations/${encodeURIComponent(serviceName)}.json`;
276
278
  }
277
279
 
278
280
  generateVersionURL(serviceName, termsType) {
279
- return `https://github.com/${this.commonParams.owner}/${this.commonParams.repo}/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}.md`;
281
+ return `https://github.com/${this.repositories.versions}/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}.md`;
280
282
  }
281
283
 
282
284
  generateSnapshotsBaseUrl(serviceName, termsType) {
283
- return `https://github.com/${this.commonParams.owner}/${this.commonParams.repo}/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}`;
285
+ return `https://github.com/${this.repositories.snapshots}/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}`;
284
286
  }
285
287
  }
@@ -10,12 +10,13 @@ describe('GitHub', function () {
10
10
 
11
11
  let MANAGED_LABELS;
12
12
  let github;
13
+ const REPOSITORIES = { declarations: 'owner/repo', versions: 'owner/versions-repo', snapshots: 'owner/snapshots-repo' };
13
14
  const EXISTING_OPEN_ISSUE = { number: 1, title: 'Opened issue', description: 'Issue description', state: GitHub.ISSUE_STATE_OPEN, labels: [{ name: LABELS.HTTP_403.name }] };
14
15
  const EXISTING_CLOSED_ISSUE = { number: 2, title: 'Closed issue', description: 'Issue description', state: GitHub.ISSUE_STATE_CLOSED, labels: [{ name: LABELS.EMPTY_CONTENT.name }] };
15
16
 
16
17
  before(async () => {
17
18
  MANAGED_LABELS = Object.values(LABELS);
18
- github = new GitHub('owner/repo');
19
+ github = new GitHub(REPOSITORIES);
19
20
  nock('https://api.github.com')
20
21
  .get('/repos/owner/repo/issues')
21
22
  .query(true)
@@ -518,4 +519,18 @@ describe('GitHub', function () {
518
519
  });
519
520
  });
520
521
  });
522
+
523
+ describe('URL generation', () => {
524
+ it('links the declaration into the declarations repository', () => {
525
+ expect(github.generateDeclarationURL('Service A')).to.equal('https://github.com/owner/repo/blob/main/declarations/Service%20A.json');
526
+ });
527
+
528
+ it('links the version into the versions repository', () => {
529
+ expect(github.generateVersionURL('Service A', 'Terms of Service')).to.equal('https://github.com/owner/versions-repo/blob/main/Service%20A/Terms%20of%20Service.md');
530
+ });
531
+
532
+ it('links the snapshots into the snapshots repository', () => {
533
+ expect(github.generateSnapshotsBaseUrl('Service A', 'Terms of Service')).to.equal('https://github.com/owner/snapshots-repo/blob/main/Service%20A/Terms%20of%20Service');
534
+ });
535
+ });
521
536
  });
@@ -14,8 +14,8 @@ export default class GitLab {
14
14
  static ISSUE_STATE_ALL = 'all';
15
15
  static MAX_LABEL_DESCRIPTION_LENGTH = 255;
16
16
 
17
- constructor(repository, baseURL = BASE_URL, apiBaseURL = API_BASE_URL) {
18
- this.repositoryPath = repository;
17
+ constructor(repositories, baseURL = BASE_URL, apiBaseURL = API_BASE_URL) {
18
+ this.repositories = repositories; // Each generated link points into its own repository, while the issues live in the declarations one
19
19
  this.projectId = null;
20
20
  this.baseURL = baseURL;
21
21
  this.apiBaseURL = apiBaseURL;
@@ -26,7 +26,7 @@ export default class GitLab {
26
26
 
27
27
  try {
28
28
  const response = await nodeFetch(
29
- `${this.apiBaseURL}/projects/${encodeURIComponent(this.repositoryPath)}`,
29
+ `${this.apiBaseURL}/projects/${encodeURIComponent(this.repositories.declarations)}`,
30
30
  options,
31
31
  );
32
32
 
@@ -478,15 +478,15 @@ export default class GitLab {
478
478
  }
479
479
 
480
480
  generateDeclarationURL(serviceName) {
481
- return `${this.baseURL}/${this.repositoryPath}/-/blob/main/declarations/${encodeURIComponent(serviceName)}.json`;
481
+ return `${this.baseURL}/${this.repositories.declarations}/-/blob/main/declarations/${encodeURIComponent(serviceName)}.json`;
482
482
  }
483
483
 
484
484
  generateVersionURL(serviceName, termsType) {
485
- return `${this.baseURL}/${this.repositoryPath}/-/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}.md`;
485
+ return `${this.baseURL}/${this.repositories.versions}/-/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}.md`;
486
486
  }
487
487
 
488
488
  generateSnapshotsBaseUrl(serviceName, termsType) {
489
- return `${this.baseURL}/${this.repositoryPath}/-/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}`;
489
+ return `${this.baseURL}/${this.repositories.snapshots}/-/blob/main/${encodeURIComponent(serviceName)}/${encodeURIComponent(termsType)}`;
490
490
  }
491
491
 
492
492
  // GitLab API responses are not cached unlike GitHub, so this method only exists to satisfy the Reporter interface contract
@@ -11,10 +11,11 @@ describe('GitLab', function () {
11
11
  let MANAGED_LABELS;
12
12
  let gitlab;
13
13
  const PROJECT_ID = '4';
14
+ const REPOSITORIES = { declarations: 'owner/repo', versions: 'owner/versions-repo', snapshots: 'owner/snapshots-repo' };
14
15
 
15
16
  before(() => {
16
17
  MANAGED_LABELS = Object.values(LABELS);
17
- gitlab = new GitLab('owner/repo');
18
+ gitlab = new GitLab(REPOSITORIES);
18
19
  });
19
20
 
20
21
  describe('#initialize', () => {
@@ -767,4 +768,26 @@ describe('GitLab', function () {
767
768
  });
768
769
  });
769
770
  });
771
+
772
+ describe('URL generation', () => {
773
+ it('links the declaration into the declarations repository', () => {
774
+ expect(gitlab.generateDeclarationURL('Service A')).to.equal('https://gitlab.com/owner/repo/-/blob/main/declarations/Service%20A.json');
775
+ });
776
+
777
+ it('links the version into the versions repository', () => {
778
+ expect(gitlab.generateVersionURL('Service A', 'Terms of Service')).to.equal('https://gitlab.com/owner/versions-repo/-/blob/main/Service%20A/Terms%20of%20Service.md');
779
+ });
780
+
781
+ it('links the snapshots into the snapshots repository', () => {
782
+ expect(gitlab.generateSnapshotsBaseUrl('Service A', 'Terms of Service')).to.equal('https://gitlab.com/owner/snapshots-repo/-/blob/main/Service%20A/Terms%20of%20Service');
783
+ });
784
+
785
+ context('with a custom base URL', () => {
786
+ it('links into the custom instance', () => {
787
+ const customGitlab = new GitLab(REPOSITORIES, 'https://gitlab.example.test');
788
+
789
+ expect(customGitlab.generateVersionURL('Service A', 'Terms of Service')).to.equal('https://gitlab.example.test/owner/versions-repo/-/blob/main/Service%20A/Terms%20of%20Service.md');
790
+ });
791
+ });
792
+ });
770
793
  });
@@ -64,7 +64,7 @@ describe('Reporter', () => {
64
64
  describe('#generateDescription', () => {
65
65
  const buildReporter = () => new Reporter({
66
66
  type: 'github',
67
- repositories: { declarations: 'OpenTermsArchive/test-declarations', snapshots: 'OpenTermsArchive/test-snapshots' },
67
+ repositories: { declarations: 'OpenTermsArchive/test-declarations', versions: 'OpenTermsArchive/test-versions', snapshots: 'OpenTermsArchive/test-snapshots' },
68
68
  });
69
69
 
70
70
  const buildTerms = ({ sourceCount = 1, hasSnapshots = true, withoutSnapshotIndexes = [] } = {}) => {
@@ -159,5 +159,32 @@ describe('Reporter', () => {
159
159
  expect(description.length).to.be.lessThan(65000);
160
160
  });
161
161
  });
162
+
163
+ context('references links', () => {
164
+ it('links the latest version into the versions repository', () => {
165
+ const description = buildReporter().generateDescription({ error, terms: buildTerms() });
166
+
167
+ expect(description).to.include('github.com/OpenTermsArchive/test-versions/blob/main/TestService/Terms%20of%20Service.md');
168
+ });
169
+
170
+ it('links the latest snapshots into the snapshots repository', () => {
171
+ const description = buildReporter().generateDescription({ error, terms: buildTerms() });
172
+
173
+ expect(description).to.include('github.com/OpenTermsArchive/test-snapshots/blob/main/TestService/Terms%20of%20Service');
174
+ expect(description).to.not.include('test-declarations/blob/main/TestService/Terms%20of%20Service'); // A version or snapshot link into the declarations repository is the regression this guards against
175
+ });
176
+
177
+ context('when only the declarations repository is configured', () => {
178
+ it('omits the version and snapshots links instead of generating broken ones', () => {
179
+ const reporter = new Reporter({ type: 'github', repositories: { declarations: 'OpenTermsArchive/test-declarations' } });
180
+
181
+ const description = reporter.generateDescription({ error, terms: buildTerms() });
182
+
183
+ expect(description).to.not.include('Latest version');
184
+ expect(description).to.not.include('Latest snapshot');
185
+ expect(description).to.not.include('undefined');
186
+ });
187
+ });
188
+ });
162
189
  });
163
190
  });