- {% assign helpId = 'sm' %}
+
{% include helpfulness %}
{% include contribution %}
diff --git a/includes/helpfulness.html b/includes/helpfulness.html
index b4d8a04d5beb..ccbea5746db6 100644
--- a/includes/helpfulness.html
+++ b/includes/helpfulness.html
@@ -1,5 +1,5 @@
{% unless enterpriseServerReleases.isOldestReleaseDeprecated and currentVersion contains enterpriseServerReleases.oldestSupported %}
-
-{% endunless %}
\ No newline at end of file
+{% endunless %}
diff --git a/includes/release-notes-category-label.html b/includes/release-notes-category-label.html
new file mode 100644
index 000000000000..d100d9abaa3a
--- /dev/null
+++ b/includes/release-notes-category-label.html
@@ -0,0 +1,12 @@
+{% assign sectionTitle = section.title | default: 'Misc' %}
+
+{% case sectionTitle %}
+ {% when "New Feature" %}
+ {% assign colors = "bg-orange text-white" %}
+ {% when "Bugs" %}
+ {% assign colors = "bg-purple text-white" %}
+ {% else %}
+ {% assign colors = "bg-blue text-white" %}
+{% endcase %}
+
+{{ sectionTitle }}
diff --git a/javascripts/experiment.js b/javascripts/experiment.js
index ba501f52d9d5..200e718455cd 100644
--- a/javascripts/experiment.js
+++ b/javascripts/experiment.js
@@ -41,28 +41,4 @@ export default function () {
// const xbucket = bucket(testName)
// if (xbucket === TREATMENT) { ... }
// x.addEventListener('click', () => { sendSuccess(testName) })
-
- const testName = 'helpfulness-prompt-to-bottom'
- const xbucket = bucket(testName)
-
- if (xbucket === TREATMENT) {
- const bigHelpfulness = document.querySelector('#helpfulness-xl')
- const smallHelpfulness = document.querySelector('#helpfulness-sm')
-
- // Check that helpfulness prompt is present on this page
- if (!(bigHelpfulness && smallHelpfulness)) {
- return
- }
-
- // Remove the -xl prompts
- bigHelpfulness.parentElement.parentElement.removeChild(bigHelpfulness.parentElement)
-
- // Always show the -sm prompt
- smallHelpfulness.parentElement.classList.remove('d-xl-none')
- }
-
- const votes = Array.from(document.querySelectorAll('.js-helpfulness [type=radio]'))
- votes.forEach(voteEl => {
- voteEl.addEventListener('change', () => { sendSuccess(testName) })
- })
}
diff --git a/javascripts/helpfulness.js b/javascripts/helpfulness.js
index 01d95ebf51a7..f5bc95ee7209 100644
--- a/javascripts/helpfulness.js
+++ b/javascripts/helpfulness.js
@@ -1,17 +1,15 @@
import { sendEvent } from './events'
export default function helpfulness () {
- const forms = Array.from(document.querySelectorAll('.js-helpfulness'))
+ const form = document.querySelector('.js-helpfulness')
const texts = Array.from(document.querySelectorAll('.js-helpfulness input, .js-helpfulness textarea'))
const votes = Array.from(document.querySelectorAll('.js-helpfulness [type=radio]'))
- if (!forms.length || !texts.length || !votes.length) return
+ if (!form || !texts.length || !votes.length) return
- forms.forEach(form => {
- form.addEventListener('submit', async evt => {
- evt.preventDefault()
- await submitForm(evt.target)
- updateDisplay(form, 'end')
- })
+ form.addEventListener('submit', async evt => {
+ evt.preventDefault()
+ await submitForm(evt.target)
+ updateDisplay(form, 'end')
})
votes.forEach(voteEl => {
diff --git a/layouts/release-notes.html b/layouts/release-notes.html
new file mode 100644
index 000000000000..ce3764084c15
--- /dev/null
+++ b/layouts/release-notes.html
@@ -0,0 +1,93 @@
+{% assign product = siteTree[currentLanguage][currentVersion].products[currentProduct] %}
+
+
+
+ {% include head %}
+
+
+ {% include sidebar %}
+
+
+ {% include header %}
+ {% include deprecation-banner %}
+
+
+
+ {% include article-version-switcher %}
+
+ {% include breadcrumbs %}
+
+ {% include article-version-switcher %}
+
+
+
+
+
+ {{ allVersions[currentVersion].versionTitle }}
+
+
+ {% for patch in releaseNotes %}
+
+
+
+ {{ patch.version }}
+
+ {% if patch.release_candidate %}
+ Release Candidate
+ {% endif %}
+
+
+
+ {{ patch.date | date: "%B %d, %Y" }}
+
+
+
+
+
+ {{ patch.intro }}
+
+ {% for section in patch.sortedNotes %}
+
+
+ {% include 'release-notes-category-label' %}
+
+
+
+ {% for note in section.notes %}
+ {% if note.note and note.note != 'n/a
' %}
+ -
+ {{ note.note }}
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+
+
+
+ {% include support %}
+ {% include small-footer %}
+
+
+
diff --git a/lib/liquid-tags/extended-markdown.js b/lib/liquid-tags/extended-markdown.js
index 1037b6d7c2ac..cf018b0845e1 100644
--- a/lib/liquid-tags/extended-markdown.js
+++ b/lib/liquid-tags/extended-markdown.js
@@ -11,7 +11,7 @@ const tags = {
danger: 'border rounded-1 mb-4 p-3 border-red bg-red-light f5'
}
-const template = '{{ output }}'
+const template = '\n{{ output }}\n'
class ExtendedMarkdown extends Liquid.Block {
async render (context) {
diff --git a/lib/release-notes-schema.js b/lib/release-notes-schema.js
new file mode 100644
index 000000000000..e58278e29ad4
--- /dev/null
+++ b/lib/release-notes-schema.js
@@ -0,0 +1,34 @@
+module.exports = {
+ properties: {
+ intro: {
+ type: 'string',
+ required: true
+ },
+ date: {
+ type: 'string',
+ format: 'date',
+ required: true
+ },
+ release_candidate: {
+ type: 'boolean',
+ default: false
+ },
+ notes: {
+ required: true,
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ note: {
+ type: 'string',
+ required: true
+ },
+ type: {
+ type: 'string',
+ required: true
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lib/render-content.js b/lib/render-content.js
index ff18773c5d41..99cc81072803 100644
--- a/lib/render-content.js
+++ b/lib/render-content.js
@@ -26,6 +26,13 @@ renderContent.liquid.registerFilters({
obj_size: (input) => {
if (!input) return 0
return Object.keys(input).length
+ },
+ /**
+ * Returns the version number of a GHES version string
+ * ex: enterprise-server@2.22 => 2.22
+ */
+ version_num: (input) => {
+ return input.split('@')[1]
}
})
diff --git a/lib/warm-server.js b/lib/warm-server.js
index f9c64f99ebc3..d98f5a914723 100644
--- a/lib/warm-server.js
+++ b/lib/warm-server.js
@@ -1,7 +1,8 @@
+const statsd = require('./statsd')
const fetchEarlyAccessPaths = require('./fetch-early-access-paths')
let pages, site, redirects, siteTree, earlyAccessPaths
-module.exports = async function warmServer () {
+async function warmServer () {
if (!pages) {
if (process.env.NODE_ENV !== 'test') {
console.log('Priming context information')
@@ -22,3 +23,7 @@ module.exports = async function warmServer () {
pages, site, redirects, siteTree, earlyAccessPaths
}
}
+
+// Instrument the `warmServer` function so that
+// it's wrapped in a timer that reports to Datadog
+module.exports = statsd.asyncTimer(warmServer, 'warm_server')
diff --git a/middleware/contextualizers/enterprise-release-notes.js b/middleware/contextualizers/enterprise-release-notes.js
new file mode 100644
index 000000000000..e871114426cb
--- /dev/null
+++ b/middleware/contextualizers/enterprise-release-notes.js
@@ -0,0 +1,65 @@
+const renderContent = require('../../lib/render-content')
+const patterns = require('../../lib/patterns')
+
+module.exports = async (req, res, next) => {
+ // The `/release-notes` sub-path
+ if (!req.path.endsWith('/release-notes')) return next()
+
+ // ignore paths that don't have an enterprise version number
+ if (!patterns.getEnterpriseServerNumber.test(req.path)) return next()
+
+ // extract enterprise version from path, e.g. 2.16
+ const requestedVersion = req.path.match(patterns.getEnterpriseServerNumber)[1]
+
+ const versionString = `${requestedVersion.replace(/\./g, '-')}`
+
+ const allReleaseNotes = req.context.site.data['release-notes']
+
+ // This version doesn't have any release notes - let's be helpful and redirect
+ // to the notes on `enterprise.github.com`
+ if (!allReleaseNotes || !allReleaseNotes[versionString]) {
+ return res.redirect(`https://enterprise.github.com/releases/${requestedVersion}.0/notes`)
+ }
+
+ const releaseNotes = allReleaseNotes[versionString]
+ const keys = Object.keys(releaseNotes)
+ // Turn { [key]: { notes, intro, date } }
+ // into [{ version, notes, intro, date }]
+ const patches = keys
+ .sort((a, b) => {
+ if (a > b) return -1
+ if (a < b) return 1
+ return 0
+ })
+ .map(key => ({ version: `${requestedVersion}.${key}`, ...releaseNotes[key] }))
+
+ const renderedPatches = await Promise.all(patches.map(async patch => {
+ // Render the intro block, it might contain markdown formatting
+ patch.intro = await renderContent(patch.intro, req.context)
+
+ // Run the notes through the markdown rendering pipeline
+ patch.notes = await Promise.all(patch.notes.map(async note => {
+ if (note.note) note.note = await renderContent(note.note, req.context)
+ return note
+ }))
+
+ // Sort the notes into sections
+ // Takes an array of notes: Array<{ note, type }>
+ // Turns it into { [type]: [{ note }] }
+ patch.sortedNotes = patch.notes.reduce((prev, curr) => {
+ const existingObj = prev.find(o => o.title === curr.type)
+ if (!existingObj) {
+ prev.push({ title: curr.type, notes: [curr] })
+ } else {
+ existingObj.notes.push(curr)
+ }
+ return prev
+ }, [])
+
+ return patch
+ }))
+
+ req.context.releaseNotes = renderedPatches
+
+ return next()
+}
diff --git a/middleware/index.js b/middleware/index.js
index ce3ea112904a..7c7d7f672e2b 100644
--- a/middleware/index.js
+++ b/middleware/index.js
@@ -69,6 +69,7 @@ module.exports = function (app) {
app.get('/_500', asyncMiddleware(require('./trigger-error')))
// *** Preparation for render-page ***
+ app.use(asyncMiddleware(require('./contextualizers/enterprise-release-notes')))
app.use(require('./contextualizers/graphql'))
app.use(require('./contextualizers/rest'))
app.use(require('./contextualizers/webhooks'))
diff --git a/script/README.md b/script/README.md
index bb718663da24..ec7beb9881fd 100644
--- a/script/README.md
+++ b/script/README.md
@@ -59,7 +59,7 @@ The `ignore` array is for client-side or build-time stuff that doesn't get `requ
### [`check-english-links.js`](check-english-links.js)
-This script runs once per day via a scheduled GitHub Action to check all links in English content, not including deprecated Enterprise Server content. It opens an issue if it finds broken links. To exclude a link, add it to `lib/excluded-links.js`.
+This script runs once per day via a scheduled GitHub Action to check all links in English content, not including deprecated Enterprise Server content. It opens an issue if it finds broken links. To exclude a link path, add it to `lib/excluded-links.js`.
---
@@ -134,7 +134,14 @@ Run this script after an Enterprise deprecation to remove Liquid statements and
---
-### [`enterprise-server-releases/create-webhooks-for-new-version.js`](enterprise-server-releases/create-webhooks-for-new-version.js)
+### [`enterprise-server-releases/create-graphql-files.js`](enterprise-server-releases/create-graphql-files.js)
+
+This script creates the static GraphQL files for a new version.
+
+---
+
+
+### [`enterprise-server-releases/create-webhook-files.js`](enterprise-server-releases/create-webhook-files.js)
This script creates new static webhook payload files for a new version.
diff --git a/script/enterprise-server-releases/create-graphql-files.js b/script/enterprise-server-releases/create-graphql-files.js
new file mode 100755
index 000000000000..1688c016cbf0
--- /dev/null
+++ b/script/enterprise-server-releases/create-graphql-files.js
@@ -0,0 +1,83 @@
+#!/usr/bin/env node
+
+const fs = require('fs')
+const path = require('path')
+const program = require('commander')
+const allVersions = require('../../lib/all-versions')
+const graphqlDir = path.join(process.cwd(), 'lib/graphql/static')
+
+// [start-readme]
+//
+// This script creates the static GraphQL files for a new version.
+//
+// [end-readme]
+
+program
+ .description('Create GraphQL files in lib/graphql/static based on an existing version.')
+ .option('-n, --newVersion ', 'The version to copy the files to. Must be in format.')
+ .option('-o, --oldVersion ', 'The version to copy the files from. Must be in format.')
+ .parse(process.argv)
+
+const newVersion = program.newVersion
+const oldVersion = program.oldVersion
+
+if (!(newVersion && oldVersion)) {
+ console.log('Error! You must provide --newVersion and --oldVersion.')
+ process.exit(1)
+}
+
+if (!(Object.keys(allVersions).includes(newVersion) && Object.keys(allVersions).includes(oldVersion))) {
+ console.log('Error! You must provide the full name of a currently supported version, e.g., enterprise-server@2.22.')
+ process.exit(1)
+}
+
+const newVersionId = allVersions[newVersion].miscVersionName
+const oldVersionId = allVersions[oldVersion].miscVersionName
+
+// copy the schema file wholesale (there are separate schema files per version)
+const newSchemaFile = path.join(graphqlDir, `schema-${newVersionId}.json`)
+const oldSchemaFile = path.join(graphqlDir, `schema-${oldVersionId}.json`)
+fs.copyFileSync(oldSchemaFile, newSchemaFile)
+
+// check that it worked
+if (!fs.existsSync(newSchemaFile)) {
+ console.log(`Error! Can't find ${newSchemaFile}.`)
+ process.exit(1)
+}
+
+// the other files are objects with vers3091iuions as keys, so we need to require them
+const previewsFile = path.join(graphqlDir, 'previews.json')
+const changesFile = path.join(graphqlDir, 'upcoming-changes.json')
+const objectsFile = path.join(graphqlDir, 'prerendered-objects.json')
+
+const previews = require(previewsFile)
+const changes = require(changesFile)
+const objects = require(objectsFile)
+
+previews[newVersionId] = previews[oldVersionId]
+changes[newVersionId] = changes[oldVersionId]
+objects[newVersionId] = objects[oldVersionId]
+
+// check that it worked
+if (!Object.keys(previews).includes(newVersionId)) {
+ console.log(`Error! Can't find ${newVersionId} in ${previewsFile}.`)
+ process.exit(1)
+}
+
+if (!Object.keys(changes).includes(newVersionId)) {
+ console.log(`Error! Can't find ${newVersionId} in ${changesFile}.`)
+ process.exit(1)
+}
+
+if (!Object.keys(objects).includes(newVersionId)) {
+ console.log(`Error! Can't find ${newVersionId} in ${objectsFile}.`)
+ process.exit(1)
+}
+
+// write the new files
+fs.writeFileSync(previewsFile, JSON.stringify(previews, null, 2))
+fs.writeFileSync(changesFile, JSON.stringify(changes, null, 2))
+fs.writeFileSync(objectsFile, JSON.stringify(objects, null, 2))
+
+// print success message
+console.log(`Done! Copied ${oldVersion} GraphQL files to ${newVersion} files.`)
diff --git a/script/enterprise-server-releases/create-webhook-files.js b/script/enterprise-server-releases/create-webhook-files.js
index c7a3373b498a..bdded804c327 100755
--- a/script/enterprise-server-releases/create-webhook-files.js
+++ b/script/enterprise-server-releases/create-webhook-files.js
@@ -5,6 +5,7 @@ const mkdirp = require('mkdirp').sync
const path = require('path')
const program = require('commander')
const allVersions = require('../../lib/all-versions')
+const payloadsDir = 'lib/webhooks/static'
// [start-readme]
//
@@ -18,20 +19,22 @@ program
.option('-o, --oldVersion ', 'The version to copy the payloads from. Must be in format.')
.parse(process.argv)
-if (!(program.newVersion && program.oldVersion)) {
+const newVersion = program.newVersion
+const oldVersion = program.oldVersion
+
+if (!(newVersion && oldVersion)) {
console.log('Error! You must provide --newVersion and --oldVersion.')
process.exit(1)
}
-if (!(Object.keys(allVersions).includes(program.newVersion) && Object.keys(allVersions).includes(program.oldVersion))) {
- console.log('Error! You must provide the full name of a supported version, e.g., enterprise-server@2.22.')
+if (!(Object.keys(allVersions).includes(newVersion) && Object.keys(allVersions).includes(oldVersion))) {
+ console.log('Error! You must provide the full name of a currently supported version, e.g., enterprise-server@2.22.')
process.exit(1)
}
-const newVersionDirName = allVersions[program.newVersion].miscVersionName
-const oldVersionDirName = allVersions[program.oldVersion].miscVersionName
+const newVersionDirName = allVersions[newVersion].miscVersionName
+const oldVersionDirName = allVersions[oldVersion].miscVersionName
-const payloadsDir = 'lib/webhooks/static'
const srcDir = path.join(payloadsDir, oldVersionDirName)
const destDir = path.join(payloadsDir, newVersionDirName)
diff --git a/tests/browser/browser.js b/tests/browser/browser.js
index 1d99bd64e91f..f050f4e6a46c 100644
--- a/tests/browser/browser.js
+++ b/tests/browser/browser.js
@@ -134,20 +134,20 @@ describe('helpfulness', () => {
})
// When I click the "Yes" button
- await page.click('#helpfulness-sm [for=helpfulness-yes-sm]')
+ await page.click('.js-helpfulness [for=helpfulness-yes]')
// (sent a POST request to /events)
// I see the request for my email
- await page.waitForSelector('#helpfulness-sm [type="email"]')
+ await page.waitForSelector('.js-helpfulness [type="email"]')
// When I fill in my email and submit the form
- await page.type('#helpfulness-sm [type="email"]', 'test@example.com')
+ await page.type('.js-helpfulness [type="email"]', 'test@example.com')
await sleep(1000)
- await page.click('#helpfulness-sm [type="submit"]')
+ await page.click('.js-helpfulness [type="submit"]')
// (sent a PUT request to /events/{id})
// I see the feedback
- await page.waitForSelector('#helpfulness-sm [data-help-end]')
+ await page.waitForSelector('.js-helpfulness [data-help-end]')
})
})
diff --git a/tests/content/lint-files.js b/tests/content/lint-files.js
index 1714f0b041ce..10e188a24a36 100644
--- a/tests/content/lint-files.js
+++ b/tests/content/lint-files.js
@@ -7,6 +7,8 @@ const { zip } = require('lodash')
const yaml = require('js-yaml')
const languages = require('../../lib/languages')
const { tags } = require('../../lib/liquid-tags/extended-markdown')
+const ghesReleaseNotesSchema = require('../../lib/release-notes-schema')
+const revalidator = require('revalidator')
const rootDir = path.join(__dirname, '../..')
const contentDir = path.join(rootDir, 'content')
@@ -373,6 +375,32 @@ describe('lint-files', () => {
})
}
)
+
+ // GHES release notes
+ const ghesReleaseNotesDir = path.join(__dirname, '../../data/release-notes')
+ const ghesReleaseNotesYamlAbsPaths = walk(ghesReleaseNotesDir, yamlWalkOptions).sort()
+ const ghesReleaseNotesYamlRelPaths = ghesReleaseNotesYamlAbsPaths.map(p => path.relative(rootDir, p))
+ const ghesReleaseNotesYamlTuples = zip(ghesReleaseNotesYamlRelPaths, ghesReleaseNotesYamlAbsPaths)
+
+ if (ghesReleaseNotesYamlTuples.length > 0) {
+ describe.each(ghesReleaseNotesYamlTuples)(
+ 'in "%s"',
+ (yamlRelPath, yamlAbsPath) => {
+ let dictionary
+
+ beforeAll(async () => {
+ const fileContents = await fs.promises.readFile(yamlAbsPath, 'utf8')
+ dictionary = yaml.safeLoad(fileContents, { filename: yamlRelPath })
+ })
+
+ it('matches the schema', () => {
+ const { errors } = revalidator.validate(dictionary, ghesReleaseNotesSchema)
+ const errorMessage = errors.map(error => `- [${error.property}]: ${error.attribute}, ${error.message}`).join('\n')
+ expect(errors.length, errorMessage).toBe(0)
+ })
+ }
+ )
+ }
})
function formatLinkError (message, links) {
diff --git a/tests/routing/enterprise-release-notes.js b/tests/routing/enterprise-release-notes.js
new file mode 100644
index 000000000000..c88701f8be33
--- /dev/null
+++ b/tests/routing/enterprise-release-notes.js
@@ -0,0 +1,21 @@
+const { get } = require('../helpers')
+
+describe('enterprise release notes', () => {
+ jest.setTimeout(60 * 1000)
+
+ beforeAll(async () => {
+ // The first page load takes a long time so let's get it out of the way in
+ // advance to call out that problem specifically rather than misleadingly
+ // attributing it to the first test
+ await get('/')
+ })
+
+ it('redirects to the release notes on enterprise.github.com if none are present for this version here', async () => {
+ const res = await get('/en/enterprise-server@2.21/admin/release-notes')
+ expect(res.statusCode).toBe(302)
+ expect(res.headers.location).toBe('https://enterprise.github.com/releases/2.21.0/notes')
+ })
+
+ // We can't write this test until we have real release notes
+ it.todo('renders the release-notes layout if this version\'s release notes are in this repo')
+})
{{ allVersions[currentVersion].versionTitle }}
+
+
+ {{ patch.version }}
+
+ {% if patch.release_candidate %}
+ Release Candidate
+ {% endif %}
+
+
+
+ {{ patch.date | date: "%B %d, %Y" }}
+
+
+
+
+
+ {{ patch.intro }}
+ + {% for section in patch.sortedNotes %} +-
+ {% for note in section.notes %}
+ {% if note.note and note.note != '
- + {{ note.note }} + + {% endif %} + {% endfor %} +
n/a
' %} +