@opentermsarchive/engine 1.1.0 → 1.1.2

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/.eslintrc.yaml CHANGED
@@ -100,6 +100,7 @@ rules:
100
100
  new-cap:
101
101
  - error
102
102
  - properties: false
103
+ require-await: 1
103
104
 
104
105
  overrides:
105
106
  - files:
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
- "homepage": "https://github.com/OpenTermsArchive/engine#readme",
5
+ "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
7
7
  "url": "https://github.com/OpenTermsArchive/engine/issues"
8
8
  },
@@ -11,7 +11,7 @@
11
11
  "url": "git+https://github.com/OpenTermsArchive/engine.git"
12
12
  },
13
13
  "license": "EUPL-1.2",
14
- "author": "ambanum",
14
+ "author": "Open Terms Archive contributors",
15
15
  "type": "module",
16
16
  "exports": {
17
17
  ".": "./src/exports.js",
@@ -65,7 +65,6 @@
65
65
  "croner": "^4.3.6",
66
66
  "cross-env": "^7.0.3",
67
67
  "datauri": "^4.1.0",
68
- "deep-diff": "^1.0.2",
69
68
  "dotenv": "^10.0.0",
70
69
  "eslint": "^8.53.0",
71
70
  "eslint-config-airbnb-base": "^15.0.0",
@@ -31,7 +31,7 @@ export default async options => {
31
31
  if (options.modified) {
32
32
  const declarationUtils = new DeclarationUtils(instancePath);
33
33
 
34
- ({ services: servicesToValidate } = await declarationUtils.getModifiedServiceTermsTypes());
34
+ ({ services: servicesToValidate } = await declarationUtils.getModifiedServicesAndTermsTypes());
35
35
  }
36
36
 
37
37
  const lintFile = lintAndFixFile(options.fix);
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "Service",
3
+ "documents": {
4
+ "Terms of Service": {
5
+ "fetch": "https://domain.example/tos",
6
+ "select": "body"
7
+ },
8
+ "Privacy Policy": {
9
+ "fetch": "https://domain.example/privacy",
10
+ "select": "body"
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "Service",
3
+ "documents": {
4
+ "Terms of Service": {
5
+ "fetch": "https://domain.example/tos",
6
+ "select": "body",
7
+ "remove": "footer"
8
+ },
9
+ "Privacy Policy": {
10
+ "fetch": "https://domain.example/privacy",
11
+ "select": "body",
12
+ "remove": "footer"
13
+ },
14
+ "Imprint": {
15
+ "fetch": "https://domain.example/imprint",
16
+ "select": "body",
17
+ "remove": "footer"
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "Service",
3
+ "documents": {
4
+ "Terms of Service": {
5
+ "fetch": "https://domain.example/tos",
6
+ "select": "body"
7
+ },
8
+ "Privacy Policy": {
9
+ "fetch": "https://domain.example/privacy",
10
+ "select": "body"
11
+ },
12
+ "Imprint": {
13
+ "fetch": "https://domain.example/imprint",
14
+ "select": "body"
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "Service",
3
+ "documents": {
4
+ "Terms of Service": {
5
+ "fetch": "https://domain.example/tos",
6
+ "select": "body"
7
+ }
8
+ }
9
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "Service",
3
+ "documents": {
4
+ "Terms of Service": {
5
+ "fetch": "https://domain.example/tos",
6
+ "select": "body",
7
+ "remove": "footer"
8
+ },
9
+ "Privacy Policy": {
10
+ "fetch": "https://domain.example/privacy",
11
+ "select": "body"
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "Service B",
3
+ "documents": {
4
+ "Terms of Service": {
5
+ "fetch": "https://domain.example/tos",
6
+ "select": "body"
7
+ }
8
+ }
9
+ }
@@ -1,6 +1,5 @@
1
1
  import path from 'path';
2
2
 
3
- import DeepDiff from 'deep-diff';
4
3
  import simpleGit from 'simple-git';
5
4
 
6
5
  export default class DeclarationUtils {
@@ -9,13 +8,17 @@ export default class DeclarationUtils {
9
8
  this.defaultBranch = defaultBranch;
10
9
  }
11
10
 
12
- static filePathToServiceId = filePath => path.parse(filePath.replace(/\.history|\.filters/, '')).name;
11
+ static getServiceIdFromFilePath(filePath) {
12
+ return path.parse(filePath.replace(/\.history|\.filters/, '')).name;
13
+ }
13
14
 
14
- async getJSONFile(path, ref) {
15
+ async getJSONFromFile(ref, filePath) {
15
16
  try {
16
- return JSON.parse(await this.git.show([`${ref}:${path}`]));
17
- } catch (e) {
18
- return {};
17
+ const fileContent = await this.git.show([`${ref}:${filePath}`]);
18
+
19
+ return JSON.parse(fileContent);
20
+ } catch (error) {
21
+ // the file does not exist on the requested branch or it is not parsable
19
22
  }
20
23
  }
21
24
 
@@ -24,7 +27,7 @@ export default class DeclarationUtils {
24
27
 
25
28
  const modifiedFilePaths = (modifiedFilePathsAsString ? modifiedFilePathsAsString.split('\0') : []).filter(str => str !== ''); // split on \0 rather than \n due to the -z option of git diff
26
29
 
27
- return { modifiedFilePaths, modifiedServicesIds: Array.from(new Set(modifiedFilePaths.map(DeclarationUtils.filePathToServiceId))) };
30
+ return { modifiedFilePaths, modifiedServicesIds: Array.from(new Set(modifiedFilePaths.map(DeclarationUtils.getServiceIdFromFilePath))) };
28
31
  }
29
32
 
30
33
  async getModifiedServices() {
@@ -33,48 +36,47 @@ export default class DeclarationUtils {
33
36
  return modifiedServicesIds;
34
37
  }
35
38
 
36
- async getModifiedServiceTermsTypes() {
37
- const { modifiedFilePaths, modifiedServicesIds } = await this.getModifiedData();
39
+ async getModifiedServicesAndTermsTypes() {
40
+ const { modifiedFilePaths } = await this.getModifiedData();
38
41
  const servicesTermsTypes = {};
39
42
 
40
43
  await Promise.all(modifiedFilePaths.map(async modifiedFilePath => {
41
- const serviceId = DeclarationUtils.filePathToServiceId(modifiedFilePath);
44
+ const serviceId = DeclarationUtils.getServiceIdFromFilePath(modifiedFilePath);
42
45
 
43
- if (!modifiedFilePath.endsWith('.json')) {
44
- // Here we should compare AST of both files to detect on which function
45
- // change has been made, and then find which terms type depends on this
46
- // function.
47
- // As this is a complicated process, we will just send back all terms types
48
- const declaration = await this.getJSONFile(`declarations/${serviceId}.json`, this.defaultBranch);
46
+ if (modifiedFilePath.endsWith('.filters.js')) {
47
+ const declaration = await this.getJSONFromFile(this.defaultBranch, `declarations/${serviceId}.json`);
49
48
 
50
- return Object.keys(declaration.documents);
49
+ servicesTermsTypes[serviceId] = Object.keys(declaration.documents); // Considering how rarely filters are used, simply return all term types that could potentially be impacted to spare implementing a function change check
50
+
51
+ return;
51
52
  }
52
53
 
53
- const defaultFile = await this.getJSONFile(modifiedFilePath, this.defaultBranch);
54
- const modifiedFile = await this.getJSONFile(modifiedFilePath, 'HEAD');
54
+ const originalService = await this.getJSONFromFile(this.defaultBranch, modifiedFilePath);
55
+ const modifiedService = await this.getJSONFromFile('HEAD', modifiedFilePath);
56
+ const modifiedServiceTermsTypes = Object.keys(modifiedService.documents);
55
57
 
56
- const diff = DeepDiff.diff(defaultFile, modifiedFile);
58
+ if (!originalService) {
59
+ servicesTermsTypes[serviceId] = modifiedServiceTermsTypes;
57
60
 
58
- if (!diff) {
59
- // This can happen if only a lint has been applied to a document
60
61
  return;
61
62
  }
62
63
 
63
- const modifiedTermsTypes = diff.reduce((acc, { path }) => {
64
- if (modifiedFilePath.includes('.history')) {
65
- acc.add(path[0]);
66
- } else if (path[0] == 'documents') {
67
- acc.add(path[1]);
68
- }
64
+ const originalServiceTermsTypes = Object.keys(originalService.documents);
65
+
66
+ modifiedServiceTermsTypes.forEach(termsType => {
67
+ const areTermsAdded = !originalServiceTermsTypes.includes(termsType);
68
+ const areTermsModified = JSON.stringify(originalService.documents[termsType]) != JSON.stringify(modifiedService.documents[termsType]);
69
69
 
70
- return acc;
71
- }, new Set());
70
+ if (!areTermsAdded && !areTermsModified) {
71
+ return;
72
+ }
72
73
 
73
- servicesTermsTypes[serviceId] = Array.from(new Set([ ...servicesTermsTypes[serviceId] || [], ...modifiedTermsTypes ]));
74
+ servicesTermsTypes[serviceId] = [termsType].concat(servicesTermsTypes[serviceId] || []);
75
+ });
74
76
  }));
75
77
 
76
78
  return {
77
- services: modifiedServicesIds,
79
+ services: Object.keys(servicesTermsTypes),
78
80
  servicesTermsTypes,
79
81
  };
80
82
  }
@@ -0,0 +1,154 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ import { expect } from 'chai';
6
+
7
+ import DeclarationUtils from './index.js';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ let declarationUtils;
12
+
13
+ const SUBJECT_PATH = path.resolve(__dirname, './test');
14
+
15
+ const FIXTURES = {
16
+ serviceA: { path: './fixtures/serviceA.json' },
17
+ serviceATermsUpdated: { path: './fixtures/serviceATermsUpdated.json' },
18
+ serviceAMultipleTermsUpdated: { path: './fixtures/serviceAMultipleTermsUpdated.json' },
19
+ serviceATermsAdded: { path: './fixtures/serviceATermsAdded.json' },
20
+ serviceATermsRemoved: { path: './fixtures/serviceATermsRemoved.json' },
21
+ serviceB: { path: './fixtures/serviceB.json' },
22
+ };
23
+
24
+ const COMMIT_PATHS = {
25
+ serviceA: './declarations/ServiceA.json',
26
+ serviceB: './declarations/ServiceB.json',
27
+ };
28
+
29
+ const commitChanges = async (filePath, content) => {
30
+ await fs.writeFile(path.resolve(SUBJECT_PATH, filePath), JSON.stringify(content, null, 2));
31
+ await declarationUtils.git.add(filePath);
32
+ await declarationUtils.git.commit('Update declarations', filePath);
33
+ };
34
+
35
+ const removeLatestCommit = async () => {
36
+ await declarationUtils.git.reset('hard', ['HEAD~1']);
37
+ };
38
+
39
+ describe.only('DeclarationUtils', () => {
40
+ describe('#getModifiedServicesAndTermsTypes', () => {
41
+ before(async () => {
42
+ await loadFixtures();
43
+ await setupRepository();
44
+ });
45
+
46
+ after(() => fs.rm(SUBJECT_PATH, { recursive: true }));
47
+
48
+ context('when an existing declaration has been modified', () => {
49
+ before(() => commitChanges(COMMIT_PATHS.serviceA, FIXTURES.serviceATermsUpdated.content));
50
+ after(removeLatestCommit);
51
+
52
+ it('returns the service ID and the updated terms type', async () => {
53
+ expect(await declarationUtils.getModifiedServicesAndTermsTypes()).to.deep.equal({
54
+ services: ['ServiceA'],
55
+ servicesTermsTypes: { ServiceA: ['Terms of Service'] },
56
+ });
57
+ });
58
+ });
59
+
60
+ context('when new terms are declared in an existing declaration', () => {
61
+ before(() => commitChanges(COMMIT_PATHS.serviceA, FIXTURES.serviceATermsAdded.content));
62
+ after(removeLatestCommit);
63
+
64
+ it('returns the service ID and the added terms type', async () => {
65
+ expect(await declarationUtils.getModifiedServicesAndTermsTypes()).to.deep.equal({
66
+ services: ['ServiceA'],
67
+ servicesTermsTypes: { ServiceA: ['Imprint'] },
68
+ });
69
+ });
70
+ });
71
+
72
+ context('when a new declaration has been added', () => {
73
+ before(() => commitChanges(COMMIT_PATHS.serviceB, FIXTURES.serviceB.content));
74
+ after(removeLatestCommit);
75
+
76
+ it('returns the added service ID along with the associated terms type', async () => {
77
+ expect(await declarationUtils.getModifiedServicesAndTermsTypes()).to.deep.equal({
78
+ services: ['ServiceB'],
79
+ servicesTermsTypes: { ServiceB: ['Terms of Service'] },
80
+ });
81
+ });
82
+ });
83
+
84
+ context('when a declaration has been removed', () => {
85
+ before(() => removeLatestCommit(declarationUtils.git));
86
+ after(async () => {
87
+ await fs.mkdir(path.resolve(SUBJECT_PATH, './declarations'), { recursive: true });
88
+ await commitChanges(COMMIT_PATHS.serviceA, FIXTURES.serviceA.content);
89
+ });
90
+
91
+ it('returns no services and no terms types', async () => {
92
+ expect(await declarationUtils.getModifiedServicesAndTermsTypes()).to.deep.equal({
93
+ services: [],
94
+ servicesTermsTypes: {},
95
+ });
96
+ });
97
+ });
98
+
99
+ context('when terms are removed from an existing declaration', () => {
100
+ before(() => commitChanges(COMMIT_PATHS.serviceA, FIXTURES.serviceATermsRemoved.content));
101
+ after(removeLatestCommit);
102
+
103
+ it('returns no services and no terms types', async () => {
104
+ expect(await declarationUtils.getModifiedServicesAndTermsTypes()).to.deep.equal({
105
+ services: [],
106
+ servicesTermsTypes: {},
107
+ });
108
+ });
109
+ });
110
+
111
+ context('when there is a combination of an updated declaration and an added declaration', () => {
112
+ before(async () => {
113
+ await commitChanges(COMMIT_PATHS.serviceA, FIXTURES.serviceAMultipleTermsUpdated.content);
114
+ await commitChanges(COMMIT_PATHS.serviceB, FIXTURES.serviceB.content);
115
+ });
116
+
117
+ after(async () => {
118
+ await removeLatestCommit();
119
+ await removeLatestCommit();
120
+ });
121
+
122
+ it('returns the services IDs and the updated terms types', async () => {
123
+ expect(await declarationUtils.getModifiedServicesAndTermsTypes()).to.deep.equal({
124
+ services: [ 'ServiceA', 'ServiceB' ],
125
+ servicesTermsTypes: {
126
+ ServiceA: [ 'Imprint', 'Privacy Policy', 'Terms of Service' ],
127
+ ServiceB: ['Terms of Service'],
128
+ },
129
+ });
130
+ });
131
+ });
132
+ });
133
+ });
134
+
135
+ async function loadFixtures() {
136
+ await Promise.all(Object.values(FIXTURES).map(async fixture => {
137
+ const content = await fs.readFile(path.resolve(__dirname, fixture.path), 'utf-8');
138
+
139
+ fixture.content = JSON.parse(content);
140
+ }));
141
+ }
142
+
143
+ async function setupRepository() {
144
+ await fs.mkdir(path.resolve(SUBJECT_PATH, './declarations'), { recursive: true });
145
+
146
+ declarationUtils = new DeclarationUtils(SUBJECT_PATH, 'main');
147
+ await declarationUtils.git.init();
148
+ await declarationUtils.git.addConfig('user.name', 'Open Terms Archive Testing Bot');
149
+ await declarationUtils.git.addConfig('user.email', 'testing-bot@opentermsarchive.org');
150
+ await declarationUtils.git.checkoutLocalBranch('main');
151
+ await commitChanges('./README.md', 'This directory is auto-generated by test executions and requires cleanup after each test run');
152
+ await commitChanges(COMMIT_PATHS.serviceA, FIXTURES.serviceA.content);
153
+ await declarationUtils.git.checkoutLocalBranch('updated');
154
+ }
@@ -37,7 +37,7 @@ export default async options => {
37
37
  if (options.modified) {
38
38
  const declarationUtils = new DeclarationUtils(instancePath);
39
39
 
40
- ({ services: servicesToValidate, servicesTermsTypes } = await declarationUtils.getModifiedServiceTermsTypes());
40
+ ({ services: servicesToValidate, servicesTermsTypes } = await declarationUtils.getModifiedServicesAndTermsTypes());
41
41
  }
42
42
 
43
43
  describe('Service declarations validation', async function () {
@@ -178,7 +178,7 @@ export default class GitHub {
178
178
  return this.createIssue({ title, description, labels: [label] });
179
179
  }
180
180
 
181
- if (issue.state == this.ISSUE_STATE_CLOSED) {
181
+ if (issue.state == GitHub.ISSUE_STATE_CLOSED) {
182
182
  await this.openIssue(issue);
183
183
  }
184
184
 
@@ -0,0 +1,473 @@
1
+ import { createRequire } from 'module';
2
+
3
+ import { expect } from 'chai';
4
+ import nock from 'nock';
5
+
6
+ import GitHub from './github.js';
7
+
8
+ const require = createRequire(import.meta.url);
9
+
10
+ describe('GitHub', function () {
11
+ this.timeout(5000);
12
+
13
+ let MANAGED_LABELS;
14
+ let github;
15
+
16
+ before(() => {
17
+ MANAGED_LABELS = require('./labels.json');
18
+ github = new GitHub('owner/repo');
19
+ });
20
+
21
+ describe('#initialize', () => {
22
+ const scopes = [];
23
+
24
+ before(async () => {
25
+ const existingLabels = MANAGED_LABELS.slice(0, -2);
26
+
27
+ nock('https://api.github.com')
28
+ .get('/repos/owner/repo/labels')
29
+ .reply(200, existingLabels);
30
+
31
+ const missingLabels = MANAGED_LABELS.slice(-2);
32
+
33
+ for (const label of missingLabels) {
34
+ scopes.push(nock('https://api.github.com')
35
+ .post('/repos/owner/repo/labels', body => body.name === label.name)
36
+ .reply(200, label));
37
+ }
38
+
39
+ await github.initialize();
40
+ });
41
+
42
+ after(nock.cleanAll);
43
+
44
+ it('should create missing labels', () => {
45
+ scopes.forEach(scope => expect(scope.isDone()).to.be.true);
46
+ });
47
+ });
48
+
49
+ describe('#getRepositoryLabels', () => {
50
+ let scope;
51
+ let result;
52
+ const LABELS = [{ name: 'bug' }, { name: 'enhancement' }];
53
+
54
+ before(async () => {
55
+ scope = nock('https://api.github.com')
56
+ .get('/repos/owner/repo/labels')
57
+ .reply(200, LABELS);
58
+
59
+ result = await github.getRepositoryLabels();
60
+ });
61
+
62
+ after(nock.cleanAll);
63
+
64
+ it('fetches repository labels', () => {
65
+ expect(scope.isDone()).to.be.true;
66
+ });
67
+
68
+ it('returns the repository labels', () => {
69
+ expect(result).to.deep.equal(LABELS);
70
+ });
71
+ });
72
+
73
+ describe('#createLabel', () => {
74
+ let scope;
75
+ const LABEL = { name: 'new_label', color: 'ffffff' };
76
+
77
+ before(async () => {
78
+ scope = nock('https://api.github.com')
79
+ .post('/repos/owner/repo/labels', body => body.name === LABEL.name)
80
+ .reply(200, LABEL);
81
+
82
+ await github.createLabel(LABEL);
83
+ });
84
+
85
+ after(nock.cleanAll);
86
+
87
+ it('creates the new label', () => {
88
+ expect(scope.isDone()).to.be.true;
89
+ });
90
+ });
91
+
92
+ describe('#createIssue', () => {
93
+ let scope;
94
+ let result;
95
+ const ISSUE = {
96
+ title: 'New Issue',
97
+ description: 'Description of the new issue',
98
+ labels: ['bug'],
99
+ };
100
+ const CREATED_ISSUE = { number: 123, ...ISSUE };
101
+
102
+ before(async () => {
103
+ scope = nock('https://api.github.com')
104
+ .post('/repos/owner/repo/issues', request => request.title === ISSUE.title && request.body === ISSUE.description && request.labels[0] === ISSUE.labels[0])
105
+ .reply(200, CREATED_ISSUE);
106
+
107
+ result = await github.createIssue(ISSUE);
108
+ });
109
+
110
+ after(nock.cleanAll);
111
+
112
+ it('creates the new issue', () => {
113
+ expect(scope.isDone()).to.be.true;
114
+ });
115
+
116
+ it('returns the created issue', () => {
117
+ expect(result).to.deep.equal(CREATED_ISSUE);
118
+ });
119
+ });
120
+
121
+ describe('#setIssueLabels', () => {
122
+ let scope;
123
+ const ISSUE_NUMBER = 123;
124
+ const LABELS = [ 'bug', 'enhancement' ];
125
+
126
+ before(async () => {
127
+ scope = nock('https://api.github.com')
128
+ .put(`/repos/owner/repo/issues/${ISSUE_NUMBER}/labels`, { labels: LABELS })
129
+ .reply(200);
130
+
131
+ await github.setIssueLabels({ issue: { number: ISSUE_NUMBER }, labels: LABELS });
132
+ });
133
+
134
+ after(nock.cleanAll);
135
+
136
+ it('sets labels on the issue', () => {
137
+ expect(scope.isDone()).to.be.true;
138
+ });
139
+ });
140
+
141
+ describe('#openIssue', () => {
142
+ let scope;
143
+ const ISSUE = { number: 123 };
144
+ const EXPECTED_REQUEST_BODY = { state: 'open' };
145
+
146
+ before(async () => {
147
+ scope = nock('https://api.github.com')
148
+ .patch(`/repos/owner/repo/issues/${ISSUE.number}`, EXPECTED_REQUEST_BODY)
149
+ .reply(200);
150
+
151
+ await github.openIssue(ISSUE);
152
+ });
153
+
154
+ after(nock.cleanAll);
155
+
156
+ it('opens the issue', () => {
157
+ expect(scope.isDone()).to.be.true;
158
+ });
159
+ });
160
+
161
+ describe('#closeIssue', () => {
162
+ let scope;
163
+ const ISSUE = { number: 123 };
164
+ const EXPECTED_REQUEST_BODY = { state: 'closed' };
165
+
166
+ before(async () => {
167
+ scope = nock('https://api.github.com')
168
+ .patch(`/repos/owner/repo/issues/${ISSUE.number}`, EXPECTED_REQUEST_BODY)
169
+ .reply(200);
170
+
171
+ await github.closeIssue(ISSUE);
172
+ });
173
+
174
+ after(nock.cleanAll);
175
+
176
+ it('closes the issue', () => {
177
+ expect(scope.isDone()).to.be.true;
178
+ });
179
+ });
180
+
181
+ describe('#getIssue', () => {
182
+ let scope;
183
+ let result;
184
+
185
+ const ISSUE = { number: 123, title: 'Test Issue' };
186
+ const ANOTHER_ISSUE = { number: 124, title: 'Test Issue 2' };
187
+
188
+ before(async () => {
189
+ scope = nock('https://api.github.com')
190
+ .get('/repos/owner/repo/issues')
191
+ .query(true)
192
+ .reply(200, [ ISSUE, ANOTHER_ISSUE ]);
193
+
194
+ result = await github.getIssue({ title: ISSUE.title });
195
+ });
196
+
197
+ after(nock.cleanAll);
198
+
199
+ it('searches for the issue', () => {
200
+ expect(scope.isDone()).to.be.true;
201
+ });
202
+
203
+ it('returns the expected issue', () => {
204
+ expect(result).to.deep.equal(ISSUE);
205
+ });
206
+ });
207
+
208
+ describe('#addCommentToIssue', () => {
209
+ let scope;
210
+ const ISSUE_NUMBER = 123;
211
+ const COMMENT = 'Test comment';
212
+
213
+ before(async () => {
214
+ scope = nock('https://api.github.com')
215
+ .post(`/repos/owner/repo/issues/${ISSUE_NUMBER}/comments`, { body: COMMENT })
216
+ .reply(200);
217
+
218
+ await github.addCommentToIssue({ issue: { number: ISSUE_NUMBER }, comment: COMMENT });
219
+ });
220
+
221
+ after(nock.cleanAll);
222
+
223
+ it('adds the comment to the issue', () => {
224
+ expect(scope.isDone()).to.be.true;
225
+ });
226
+ });
227
+
228
+ describe('#closeIssueWithCommentIfExists', () => {
229
+ after(nock.cleanAll);
230
+
231
+ context('when the issue exists and is open', () => {
232
+ const ISSUE = {
233
+ number: 123,
234
+ title: 'Open Issue',
235
+ state: GitHub.ISSUE_STATE_OPEN,
236
+ };
237
+ let addCommentScope;
238
+ let closeIssueScope;
239
+
240
+ before(async () => {
241
+ nock('https://api.github.com')
242
+ .get('/repos/owner/repo/issues')
243
+ .query(true)
244
+ .reply(200, [ISSUE]);
245
+
246
+ addCommentScope = nock('https://api.github.com')
247
+ .post(`/repos/owner/repo/issues/${ISSUE.number}/comments`)
248
+ .reply(200);
249
+
250
+ closeIssueScope = nock('https://api.github.com')
251
+ .patch(`/repos/owner/repo/issues/${ISSUE.number}`, { state: GitHub.ISSUE_STATE_CLOSED })
252
+ .reply(200);
253
+
254
+ await github.closeIssueWithCommentIfExists({ title: ISSUE.title, comment: 'Closing comment' });
255
+ });
256
+
257
+ it('adds comment to the issue', () => {
258
+ expect(addCommentScope.isDone()).to.be.true;
259
+ });
260
+
261
+ it('closes the issue', () => {
262
+ expect(closeIssueScope.isDone()).to.be.true;
263
+ });
264
+ });
265
+
266
+ context('when the issue exists and is closed', () => {
267
+ const ISSUE = {
268
+ number: 123,
269
+ title: 'Closed Issue',
270
+ state: GitHub.ISSUE_STATE_CLOSED,
271
+ };
272
+ let addCommentScope;
273
+ let closeIssueScope;
274
+
275
+ before(async () => {
276
+ nock('https://api.github.com')
277
+ .get('/repos/owner/repo/issues')
278
+ .query(true)
279
+ .reply(200, []);
280
+
281
+ addCommentScope = nock('https://api.github.com')
282
+ .post(`/repos/owner/repo/issues/${ISSUE.number}/comments`)
283
+ .reply(200);
284
+
285
+ closeIssueScope = nock('https://api.github.com')
286
+ .patch(`/repos/owner/repo/issues/${ISSUE.number}`, { state: GitHub.ISSUE_STATE_CLOSED })
287
+ .reply(200);
288
+
289
+ await github.closeIssueWithCommentIfExists({ title: ISSUE.title, comment: 'Closing comment' });
290
+ });
291
+
292
+ it('does not add comment', () => {
293
+ expect(addCommentScope.isDone()).to.be.false;
294
+ });
295
+
296
+ it('does not attempt to close the issue', () => {
297
+ expect(closeIssueScope.isDone()).to.be.false;
298
+ });
299
+ });
300
+
301
+ context('when the issue does not exist', () => {
302
+ let addCommentScope;
303
+ let closeIssueScope;
304
+
305
+ before(async () => {
306
+ nock('https://api.github.com')
307
+ .get('/repos/owner/repo/issues')
308
+ .query(true)
309
+ .reply(200, []);
310
+
311
+ addCommentScope = nock('https://api.github.com')
312
+ .post(/\/repos\/owner\/repo\/issues\/\d+\/comments/)
313
+ .reply(200);
314
+
315
+ closeIssueScope = nock('https://api.github.com')
316
+ .patch(/\/repos\/owner\/repo\/issues\/\d+/, { state: GitHub.ISSUE_STATE_CLOSED })
317
+ .reply(200);
318
+
319
+ await github.closeIssueWithCommentIfExists({ title: 'Non-existent Issue', comment: 'Closing comment' });
320
+ });
321
+
322
+ it('does not attempt to add comment', () => {
323
+ expect(addCommentScope.isDone()).to.be.false;
324
+ });
325
+
326
+ it('does not attempt to close the issue', () => {
327
+ expect(closeIssueScope.isDone()).to.be.false;
328
+ });
329
+ });
330
+ });
331
+
332
+ describe('#createOrUpdateIssue', () => {
333
+ before(async () => {
334
+ nock('https://api.github.com')
335
+ .get('/repos/owner/repo/labels')
336
+ .reply(200, MANAGED_LABELS);
337
+
338
+ await github.initialize();
339
+ });
340
+
341
+ context('when the issue does not exist', () => {
342
+ let createIssueScope;
343
+ const ISSUE_TO_CREATE = {
344
+ title: 'New Issue',
345
+ description: 'Description of the new issue',
346
+ label: 'bug',
347
+ };
348
+
349
+ before(async () => {
350
+ nock('https://api.github.com')
351
+ .get('/repos/owner/repo/issues')
352
+ .query(true)
353
+ .reply(200, []); // Simulate that there is no issues on the repository
354
+
355
+ createIssueScope = nock('https://api.github.com')
356
+ .post('/repos/owner/repo/issues', {
357
+ title: ISSUE_TO_CREATE.title,
358
+ body: ISSUE_TO_CREATE.description,
359
+ labels: [ISSUE_TO_CREATE.label],
360
+ })
361
+ .reply(200, { number: 123 });
362
+
363
+ await github.createOrUpdateIssue(ISSUE_TO_CREATE);
364
+ });
365
+
366
+ it('creates the issue', () => {
367
+ expect(createIssueScope.isDone()).to.be.true;
368
+ });
369
+ });
370
+
371
+ context('when the issue already exists', () => {
372
+ const ISSUE = {
373
+ title: 'Existing Issue',
374
+ description: 'New comment',
375
+ label: 'location',
376
+ };
377
+
378
+ context('when issue is closed', () => {
379
+ let setIssueLabelsScope;
380
+ let addCommentScope;
381
+ let openIssueScope;
382
+
383
+ const GITHUB_RESPONSE_FOR_EXISTING_ISSUE = {
384
+ number: 123,
385
+ title: ISSUE.title,
386
+ description: ISSUE.description,
387
+ labels: [{ name: 'selectors' }],
388
+ state: GitHub.ISSUE_STATE_CLOSED,
389
+ };
390
+
391
+ before(async () => {
392
+ nock('https://api.github.com')
393
+ .get('/repos/owner/repo/issues')
394
+ .query(true)
395
+ .reply(200, [GITHUB_RESPONSE_FOR_EXISTING_ISSUE]);
396
+
397
+ openIssueScope = nock('https://api.github.com')
398
+ .patch(`/repos/owner/repo/issues/${GITHUB_RESPONSE_FOR_EXISTING_ISSUE.number}`, { state: GitHub.ISSUE_STATE_OPEN })
399
+ .reply(200);
400
+
401
+ setIssueLabelsScope = nock('https://api.github.com')
402
+ .put(`/repos/owner/repo/issues/${GITHUB_RESPONSE_FOR_EXISTING_ISSUE.number}/labels`, { labels: ['location'] })
403
+ .reply(200);
404
+
405
+ addCommentScope = nock('https://api.github.com')
406
+ .post(`/repos/owner/repo/issues/${GITHUB_RESPONSE_FOR_EXISTING_ISSUE.number}/comments`, { body: ISSUE.description })
407
+ .reply(200);
408
+
409
+ await github.createOrUpdateIssue(ISSUE);
410
+ });
411
+
412
+ it('reopens the issue', () => {
413
+ expect(openIssueScope.isDone()).to.be.true;
414
+ });
415
+
416
+ it("updates the issue's label", () => {
417
+ expect(setIssueLabelsScope.isDone()).to.be.true;
418
+ });
419
+
420
+ it('adds comment to the issue', () => {
421
+ expect(addCommentScope.isDone()).to.be.true;
422
+ });
423
+ });
424
+
425
+ context('when issue is already opened', () => {
426
+ let setIssueLabelsScope;
427
+ let addCommentScope;
428
+ let openIssueScope;
429
+
430
+ const GITHUB_RESPONSE_FOR_EXISTING_ISSUE = {
431
+ number: 123,
432
+ title: ISSUE.title,
433
+ description: ISSUE.description,
434
+ labels: [{ name: 'selectors' }],
435
+ state: GitHub.ISSUE_STATE_OPEN,
436
+ };
437
+
438
+ before(async () => {
439
+ nock('https://api.github.com')
440
+ .get('/repos/owner/repo/issues')
441
+ .query(true)
442
+ .reply(200, [GITHUB_RESPONSE_FOR_EXISTING_ISSUE]);
443
+
444
+ openIssueScope = nock('https://api.github.com')
445
+ .patch(`/repos/owner/repo/issues/${GITHUB_RESPONSE_FOR_EXISTING_ISSUE.number}`, { state: GitHub.ISSUE_STATE_OPEN })
446
+ .reply(200);
447
+
448
+ setIssueLabelsScope = nock('https://api.github.com')
449
+ .put(`/repos/owner/repo/issues/${GITHUB_RESPONSE_FOR_EXISTING_ISSUE.number}/labels`, { labels: ['location'] })
450
+ .reply(200);
451
+
452
+ addCommentScope = nock('https://api.github.com')
453
+ .post(`/repos/owner/repo/issues/${GITHUB_RESPONSE_FOR_EXISTING_ISSUE.number}/comments`, { body: ISSUE.description })
454
+ .reply(200);
455
+
456
+ await github.createOrUpdateIssue(ISSUE);
457
+ });
458
+
459
+ it('does not change the issue state', () => {
460
+ expect(openIssueScope.isDone()).to.be.false;
461
+ });
462
+
463
+ it("updates the issue's label", () => {
464
+ expect(setIssueLabelsScope.isDone()).to.be.true;
465
+ });
466
+
467
+ it('adds comment to the issue', () => {
468
+ expect(addCommentScope.isDone()).to.be.true;
469
+ });
470
+ });
471
+ });
472
+ });
473
+ });