Skip to content

Commit 2e024a6

Browse files
authored
Merge pull request #85 from highcharts/enhancement/dedupe-gh-requests
Enhancement/dedupe gh requests
2 parents 8ceb417 + cbc288d commit 2e024a6

File tree

11 files changed

+230
-57
lines changed

11 files changed

+230
-57
lines changed

.github/copilot-instructions.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copilot PR Review Instructions
2+
3+
## Review scope
4+
- Focus on correctness, security, performance, and deprecated APIs.
5+
- Flag missing tests when behaviour changes or new features land.
6+
- Prefer precise, actionable comments over summaries when issues exist.
7+
- For stylistic nits, suggest fixes only if the repository enforces them.
8+
9+
## Required checks before approval
10+
- Ensure the description explains *what* changed and *why*.
11+
- Verify that new endpoints, configs, or scripts are documented.
12+
- Confirm migrations or data changes include rollback notes.
13+
- Look for added secrets, credentials, or tokens committed by mistake.
14+
15+
## Tests and validation
16+
- Confirm automated tests cover the new functionality.
17+
- If tests are absent or incomplete, request them or supply a test plan summarising the manual steps required to validate the change.
18+
- For flaky or long-running suites, suggest targeted smoke tests that cover the risk surface.
19+
20+
## Tone and style
21+
- Keep feedback concise, professional, and specific.
22+
- Reference files and lines when calling out problems.
23+
- Offer concrete remediation ideas alongside critiques.
24+
- Acknowledge improvements or clever solutions when applicable.

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The application requires a configuration file `./config.json` to be able to run.
1818

1919
| Setting | Description |
2020
|---|---|
21-
| informationLevel | The level of severity of the information outputted to the log. The severity can be 0 (everything), 1 (warnings and errors), 2 (only errors). Defaults to 2. |
21+
| informationLevel | The level of severity of the information outputted to the log. The severity can be 0 (everything), 1 (warnings and errors), 2 (only errors). Defaults to 2. You can override this per runtime by setting the `INFORMATION_LEVEL` environment variable. |
2222
| port | The port the server application will listen to. Defaults to 80. |
2323
| secureToken | The secret token used to validate the GitHub webhook post to /update. See [GitHub Developer - Securing your webhooks](https://developer.github.com/webhooks/securing/) for more information. |
2424
| token | Personal access token used to gain access to GitHub API. The token scope is requires only access to public repositories. See [GitHub Help - Creating a personal access token for the command line](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line) for more information. |
@@ -38,6 +38,14 @@ Example config file:
3838
}
3939
```
4040

41+
To override logging without editing `config.json`, set an environment variable before starting the server, for example:
42+
43+
```bash
44+
INFORMATION_LEVEL=0 npm start
45+
```
46+
47+
Environment variables take precedence over values in `config.json`.
48+
4149
## Run the application
4250
Open a CLI and run the command: `npm start`
4351

app/JobQueue.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ class JobQueue {
2424
async doJob (queue, jobID) {
2525
const job = queue.get(jobID)
2626
if (job) {
27-
job.func(...job.args)
28-
.catch((error) => {
29-
console.log(error)
30-
})
31-
.finally(() => {
32-
queue.delete(jobID)
33-
job.setDone(true)
34-
})
27+
try {
28+
await Promise.resolve().then(() => job.func(...job.args))
29+
} catch (error) {
30+
console.log(error)
31+
} finally {
32+
queue.delete(jobID)
33+
job.setDone(true)
34+
}
3535
await job.done
3636
}
3737
}

app/JobQueue.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/dashboards.js

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const { join } = require('node:path')
44
const { stat, opendir, readFile, writeFile, symlink } = require('node:fs/promises')
55
const { existsSync } = require('node:fs')
66
const { downloadSourceFolder, getBranchInfo, getCommitInfo } = require('./download')
7-
const { compileTypeScript } = require('./utilities')
7+
const { compileTypeScript, log } = require('./utilities')
88
const { build } = require('@highcharts/highcharts-assembler/src/build')
99
const { JobQueue } = require('./JobQueue')
1010

@@ -51,15 +51,18 @@ async function assembleDashboards (pathCacheDirectory, commit) {
5151
namespace: 'Dashboards'
5252
})
5353
} catch (error) {
54-
console.log('assembler error: ', error)
54+
log(2, `Dashboards assembler error: ${error.message}`)
5555
}
5656

5757
/**
5858
* @param {string} dirPath
5959
*/
6060
async function modifyFiles (dirPath) {
6161
const dir = await opendir(dirPath)
62-
.catch(() => null)
62+
.catch(error => {
63+
log(2, `Failed to open directory ${dirPath}: ${error.message}`)
64+
return null
65+
})
6366

6467
if (dir) {
6568
for await (const dirent of dir) {
@@ -78,7 +81,10 @@ async function assembleDashboards (pathCacheDirectory, commit) {
7881
await symlink(
7982
join(dirPath, dirent.name),
8083
join(dirPath, dirent.name.replace('.src', ''))
81-
).catch(() => null)
84+
).catch(error => {
85+
log(2, `Failed to create symlink in ${dirPath}: ${error.message}`)
86+
return null
87+
})
8288
}
8389
}
8490
}
@@ -142,7 +148,7 @@ async function dashboardsHandler (req, res, next) {
142148
await stat(join(pathCacheDirectory, 'js'))
143149
} catch (err) {
144150
if (err.code === 'ENOENT') {
145-
await queue.addJob(
151+
await Promise.resolve().then(() => queue.addJob(
146152
'download',
147153
commit,
148154
{
@@ -153,7 +159,10 @@ async function dashboardsHandler (req, res, next) {
153159
commit
154160
]
155161
}
156-
).catch(handleQueueError)
162+
)).catch(error => {
163+
log(2, `Failed to enqueue download for ${commit}: ${error.message}`)
164+
return handleQueueError(error)
165+
})
157166
} else {
158167
return res.sendStatus(404)
159168
}
@@ -188,7 +197,7 @@ async function dashboardsHandler (req, res, next) {
188197
: Promise.resolve(false),
189198
// try to build and assemble file
190199
async () => {
191-
await queue.addJob(
200+
await Promise.resolve().then(() => queue.addJob(
192201
'compile',
193202
commit + filepath,
194203
{
@@ -199,16 +208,23 @@ async function dashboardsHandler (req, res, next) {
199208
? obj.compile
200209
: obj.compile.replace('.js', '.src.js')
201210
]
202-
}).catch(handleQueueError)
211+
}
212+
)).catch(error => {
213+
log(2, `Failed to enqueue TypeScript compile for ${commit}${filepath}: ${error.message}`)
214+
return handleQueueError(error)
215+
})
203216

204-
await queue.addJob(
217+
await Promise.resolve().then(() => queue.addJob(
205218
'compile',
206219
commit + filepath,
207220
{
208221
func: assembleDashboards,
209222
args: [pathCacheDirectory, commit]
210223
}
211-
).catch(handleQueueError)
224+
)).catch(error => {
225+
log(2, `Failed to enqueue dashboard assembly for ${commit}${filepath}: ${error.message}`)
226+
return handleQueueError(error)
227+
})
212228

213229
res.status(201)
214230

app/download.js

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,51 @@ const authToken = { Authorization: `token ${token}` }
1515

1616
const degit = require('tiged')
1717

18+
const DEFAULT_CACHE_TTL = Number(process.env.GITHUB_LOOKUP_CACHE_TTL || 60_000)
19+
const NEGATIVE_CACHE_TTL = Number(process.env.GITHUB_LOOKUP_NEGATIVE_CACHE_TTL || 10_000)
20+
21+
const branchInfoCache = new Map()
22+
const commitInfoCache = new Map()
23+
let githubRequest
24+
25+
/**
26+
* Resolve a value from cache or execute the provided fetcher.
27+
* Shares in-flight fetches and caches both positive and negative responses.
28+
* @template T
29+
* @param {Map<string, { expires?: number, value?: T, promise?: Promise<T> }>} cache
30+
* @param {string} key
31+
* @param {() => Promise<T>} fetcher
32+
* @returns {Promise<T>}
33+
*/
34+
function getWithCache (cache, key, fetcher) {
35+
const now = Date.now()
36+
const cached = cache.get(key)
37+
38+
if (cached) {
39+
if (cached.expires && cached.expires > now && ('value' in cached)) {
40+
return Promise.resolve(cached.value)
41+
}
42+
if (cached.promise) {
43+
return cached.promise
44+
}
45+
}
46+
47+
const promise = fetcher().then(result => {
48+
const ttl = result ? DEFAULT_CACHE_TTL : NEGATIVE_CACHE_TTL
49+
cache.set(key, {
50+
value: result,
51+
expires: Date.now() + ttl
52+
})
53+
return result
54+
}).catch(error => {
55+
cache.delete(key)
56+
throw error
57+
})
58+
59+
cache.set(key, { promise })
60+
return promise
61+
}
62+
1863
/**
1964
* Downloads the content of a url and writes it to the given output path.
2065
* The Promise is resolved with an object containing information of the result
@@ -33,7 +78,7 @@ async function downloadFile (url, outputPath) {
3378
}
3479

3580
if (statusCode === 200) {
36-
console.log(`Downloading ${url}`)
81+
log(0, `Downloading ${url}`)
3782
await writeFile(outputPath, body)
3883
result.success = true
3984
}
@@ -180,6 +225,8 @@ function get(options) {
180225
})
181226
}
182227

228+
githubRequest = get
229+
183230
/**
184231
* Gives a list of all the source files in the given branch in the repository.
185232
* The Promise resolves with a list of objects containing information on each
@@ -273,19 +320,21 @@ async function urlExists(url) {
273320
* @returns {Promise<({}|false)>}
274321
* The branch info object, or false if not found
275322
*/
276-
async function getBranchInfo(branch) {
277-
const { body, statusCode } = await get({
278-
hostname: 'api.github.com',
279-
path: `/repos/${repo}/branches/${branch}`,
280-
headers: {
281-
'user-agent': 'github.highcharts.com',
282-
...authToken
283-
}
323+
async function getBranchInfo (branch) {
324+
return getWithCache(branchInfoCache, branch, async () => {
325+
const { body, statusCode } = await githubRequest({
326+
hostname: 'api.github.com',
327+
path: `/repos/${repo}/branches/${branch}`,
328+
headers: {
329+
'user-agent': 'github.highcharts.com',
330+
...authToken
331+
}
284332
})
285333
if (statusCode === 200) {
286-
return JSON.parse(body)
334+
return JSON.parse(body)
287335
}
288336
return false
337+
})
289338
}
290339

291340

@@ -297,19 +346,30 @@ async function getBranchInfo(branch) {
297346
* @returns {Promise<({}|false)>}
298347
* The commit info object, or false if not found
299348
*/
300-
async function getCommitInfo(commit) {
301-
const { body, statusCode } = await get({
302-
hostname: 'api.github.com',
303-
path: `/repos/${repo}/commits/${commit}`,
304-
headers: {
305-
'user-agent': 'github.highcharts.com',
306-
...authToken
307-
}
349+
async function getCommitInfo (commit) {
350+
return getWithCache(commitInfoCache, commit, async () => {
351+
const { body, statusCode } = await githubRequest({
352+
hostname: 'api.github.com',
353+
path: `/repos/${repo}/commits/${commit}`,
354+
headers: {
355+
'user-agent': 'github.highcharts.com',
356+
...authToken
357+
}
308358
})
309359
if (statusCode === 200) {
310-
return JSON.parse(body)
360+
return JSON.parse(body)
311361
}
312362
return false
363+
})
364+
}
365+
366+
function setGitHubRequest (fn) {
367+
githubRequest = typeof fn === 'function' ? fn : get
368+
}
369+
370+
function clearGitHubCache () {
371+
branchInfoCache.clear()
372+
commitInfoCache.clear()
313373
}
314374

315375
// Export download functions
@@ -322,5 +382,7 @@ module.exports = {
322382
httpsGetPromise: get,
323383
urlExists,
324384
getBranchInfo,
325-
getCommitInfo
385+
getCommitInfo,
386+
__setGitHubRequest: setGitHubRequest,
387+
__clearGitHubCache: clearGitHubCache
326388
}

app/filesystem.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ async function fsStat (path) {
5757
try {
5858
return await stat(path)
5959
} catch (e) {
60+
log(2, `fsStat failed for ${path}: ${e.message}`)
6061
return false
6162
}
6263
}

app/handlers.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ const queue = new JobQueue()
5555
async function shouldDownloadTypeScriptFolders (repoURL, branch) {
5656
const urlPath = `${repoURL}${branch}/ts/tsconfig.json`
5757
const tsConfigPath = join(PATH_TMP_DIRECTORY, branch, 'ts', 'tsconfig.json')
58+
if (exists(tsConfigPath)) {
59+
return true
60+
}
5861
const tsConfigResponse = await downloadFile(urlPath, tsConfigPath)
5962

6063
return (tsConfigResponse.statusCode >= 200 && tsConfigResponse.statusCode < 300)
@@ -302,6 +305,7 @@ async function serveBuildFile (branch, requestURL, useGitDownloader = true) {
302305
return { status: 202, body: error.message }
303306
}
304307

308+
log(2, `Queue addJob failed for ${branch}: ${error.message}`)
305309
return { status: 200 }
306310
})
307311

app/utilities.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,15 @@ const util = require('util')
2222
console.log(tscPath)
2323

2424
// Import dependencies, sorted by path.
25-
const config = require('../config.json')
26-
2725
// Constants
28-
const INFORMATION_LEVEL = typeof config.informationLevel === 'number'
29-
? config.informationLevel
30-
: 2
26+
const envInformationLevel = process.env.INFORMATION_LEVEL
27+
const INFORMATION_LEVEL = (() => {
28+
if (typeof envInformationLevel !== 'undefined') {
29+
const parsed = Number(envInformationLevel)
30+
return Number.isFinite(parsed) ? parsed : 2
31+
}
32+
return 2
33+
})()
3134

3235
/**
3336
* Format a date as YYYY-MM-DDTHH-MM-SS. Returns a string with date formatted

0 commit comments

Comments
 (0)