From 31ecc1a76df80f386ba55e6f845461c26a1a4821 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Tue, 24 May 2022 18:36:16 +0200 Subject: [PATCH] [HERMES-1721] Add check-update hook functionality (#10) --- .github/maintainers_guide.md | 47 +++++++ .github/workflows/deno.yml | 4 +- .gitignore | 4 +- src/check-update.ts | 219 +++++++++++++++++++++++++++++++++ src/dev_deps.ts | 7 +- src/libraries.ts | 19 +++ src/mod.ts | 6 +- src/tests/check-update.test.ts | 67 ++++++++++ 8 files changed, 365 insertions(+), 8 deletions(-) create mode 100644 .github/maintainers_guide.md create mode 100644 src/check-update.ts create mode 100644 src/libraries.ts create mode 100644 src/tests/check-update.test.ts diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md new file mode 100644 index 0000000..959eaeb --- /dev/null +++ b/.github/maintainers_guide.md @@ -0,0 +1,47 @@ +# Maintainers Guide + +This document describes tools, tasks and workflow that one needs to be familiar with in order to effectively maintain +this project. If you use this package within your own software as is but don't plan on modifying it, this guide is +**not** for you. + +## Tools + +You will need [Deno](https://deno.land). + +## Tasks + +### Testing + +This package has unit tests in the `src/tests` directory. You can run the entire test suite via: + + deno test --allow-read --allow-env --coverage=.coverage + +To run the tests along with a coverage report: + + deno test --allow-read --allow-env --coverage=.coverage && deno coverage --exclude="fixtures|test" .coverage + +This command is also executed by GitHub Actions, the continuous integration service, for every Pull Request and branch. + +### Linting and Formatting + +This package adheres to deno lint and formatting standards. To ensure the code base adheres to these standards, run the following commands: + + deno lint ./src + deno fmt ./src + +Any warnings and errors must be addressed. + +### Releasing + +Releasing can feel intimidating at first, but rest assured: if you make a mistake, don't fret! We can always roll forward with another release 😃 + +1. Make sure your local `main` branch has the latest changes. +2. Run the tests as per the above Testing section, and any other local verification, such as: + - Local integration tests between the Slack CLI, deno-sdk-based application template(s) and this repo. One can modify a deno-sdk-based app project's `slack.json` file to point the `get-hooks` hook to a local version of this repo rather than the deno.land-hosted version. +3. Bump the version number for this repo in adherence to [Semantic Versioning](http://semver.org/) in `src/libraries.ts`, specifically the `VERSIONS` map's `DENO_SLACK_HOOKS` key. + - Make a single commit with a message for the version bump. +4. Tag the version commit with a tag matching the version number. I.e. if you are releasing version 1.2.3 of this repo, then the git tag should be `1.2.3`. + - This can be done with the command: `git tag 1.2.3` +5. Push the commit and tag to GitHub: `git push --tags origin main`. This will kick off an automatic deployment to https://deno.land/x/deno_slack_hooks +6. Create a GitHub Release based on the newly-created tag with release notes. + - From the repository, navigate to the **Releases** section and draft a new release. You can use prior releases as a template. diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml index c4137d7..518a22b 100644 --- a/.github/workflows/deno.yml +++ b/.github/workflows/deno.yml @@ -35,5 +35,5 @@ jobs: - name: Run linter run: deno lint ./src # Once we have tests we can uncomment the below - #- name: Run tests - #run: deno test --allow-read --allow-env --coverage=.coverage && deno coverage --exclude="fixtures|test" .coverage + - name: Run tests + run: deno test --allow-read --allow-env --coverage=.coverage && deno coverage --exclude="fixtures|test" .coverage diff --git a/.gitignore b/.gitignore index 494bb12..2cf6e14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -.DS_Store -.coverage \ No newline at end of file +.coverage +.DS_Store \ No newline at end of file diff --git a/src/check-update.ts b/src/check-update.ts new file mode 100644 index 0000000..e75929e --- /dev/null +++ b/src/check-update.ts @@ -0,0 +1,219 @@ +import { + DENO_SLACK_API, + DENO_SLACK_HOOKS, + DENO_SLACK_SDK, +} from "./libraries.ts"; + +const IMPORT_MAP_SDKS = [DENO_SLACK_SDK, DENO_SLACK_API]; +const SLACK_JSON_SDKS = [ + DENO_SLACK_HOOKS, // should be the only one needed now that the get-hooks hook is supported +]; + +interface UpdateResponse { + name: string; + releases: Release[]; + message?: string; + url?: string; + error?: { + message: string; + } | null; +} + +interface VersionMap { + [key: string]: Release; +} + +interface Release { + name: string; + current?: string; + latest?: string; + update?: boolean; + breaking?: boolean; + message?: string; + url?: string; + error?: { + message: string; + } | null; +} + +export const checkForSDKUpdates = async () => { + const versionMap: VersionMap = await createVersionMap(); + const updateResp = createUpdateResp(versionMap); + return updateResp; +}; + +/** + * createVersionMap creates an object that contains each dependency, + * featuring information about the current and latest versions, as well + * as if breaking changes are present and if any errors occurred during + * version retrieval. + */ +async function createVersionMap() { + const versionMap: VersionMap = await readProjectDependencies(); + + // Check each dependency for updates, classify update as breaking or not, + // craft message with information retrieved, and note any error that occurred. + for (const [sdk, value] of Object.entries(versionMap)) { + if (value) { + const current = versionMap[sdk].current || ""; + let latest = "", error = null; + + try { + latest = await fetchLatestModuleVersion(sdk); + } catch (err) { + error = err; + } + + const update = (!!current && !!latest) && current !== latest; + const breaking = hasBreakingChange(current, latest); + + versionMap[sdk] = { + ...versionMap[sdk], + latest, + update, + breaking, + error, + }; + } + } + + return versionMap; +} + +/** readProjectDependencies reads from possible dependency files + * (import_map.json, slack.json) and maps them to the versionMap + * containing each dependency's update information + */ +async function readProjectDependencies(): Promise { + const cwd = Deno.cwd(); + const versionMap: VersionMap = {}; + + // Find SDK component versions in import map, if available + const map = await getJson(`${cwd}/import_map.json`); + for (const sdkUrl of Object.values(map.imports) as string[]) { + for (const sdk of IMPORT_MAP_SDKS) { + if (sdkUrl.includes(sdk)) { + versionMap[sdk] = { + name: sdk, + current: extractVersion(sdkUrl), + }; + } + } + } + + // Find SDK component versions in slack.json, if available + const { hooks }: { [key: string]: string } = await getJson( + `${cwd}/slack.json`, + ); + for (const command of Object.values(hooks)) { + for (const sdk of SLACK_JSON_SDKS) { + if (command.includes(sdk)) { + versionMap[sdk] = { + name: sdk, + current: extractVersion(command), + }; + } + } + } + + return versionMap; +} + +/** + * getJson attempts to read the given file. If successful, + * it returns an object of the contained JSON. If the extraction + * fails, it returns an empty object. + */ +async function getJson(file: string) { + try { + return JSON.parse(await Deno.readTextFile(file)); + } catch (_) { + return {}; + } +} + +/** fetchLatestModuleVersion makes a call to deno.land with the + * module name and returns the extracted version number, if found + */ +export async function fetchLatestModuleVersion( + moduleName: string, +): Promise { + const res = await fetch(`https://deno.land/x/${moduleName}`, { + redirect: "manual", + }); + + const redirect = res.headers.get("location"); + if (redirect === null) { + throw new Error(`${moduleName} not found on deno.land!`); + } + + return extractVersion(redirect); +} + +export function extractVersion(str: string) { + const at = str.indexOf("@"); + + // Doesn't contain an @ version + if (at === -1) return ""; + + const slash = str.indexOf("/", at); + const version = slash < at + ? str.substring(at + 1) + : str.substring(at + 1, slash); + return version; +} + +/** + * hasBreakingChange determines whether or not there is a + * major version difference of greater or equal to 1 between the current + * and latest version. + */ +function hasBreakingChange(current: string, latest: string): boolean { + const currMajor = current.split(".")[0]; + const latestMajor = latest.split(".")[0]; + return +latestMajor - +currMajor >= 1; +} + +/** + * createUpdateResp creates and returns an UpdateResponse object + * that contains information about a collection of release dependencies + * in the shape of an object that the CLI expects to consume + */ +function createUpdateResp(versionMap: VersionMap): UpdateResponse { + const name = "the Slack SDK"; + const releases = []; + const message = ""; + const url = "https://api.slack.com/future/changelog"; + + let error = null; + let errorMsg = ""; + + // Output information for each dependency + for (const sdk of Object.values(versionMap)) { + // Dependency has an update OR the fetch of update failed + if (sdk && (sdk.update || sdk.error?.message)) { + releases.push(sdk); + + // Add the dependency that failed to be fetched to the top-level error message + if (sdk.error && sdk.error.message) { + errorMsg += errorMsg + ? `, ${sdk}` + : `An error occurred fetching updates for the following packages: ${sdk.name}`; + } + } + } + + if (errorMsg) error = { message: errorMsg }; + + return { + name, + message, + releases, + url, + error, + }; +} + +if (import.meta.main) { + console.log(JSON.stringify(await checkForSDKUpdates())); +} diff --git a/src/dev_deps.ts b/src/dev_deps.ts index d15adf5..80c8b72 100644 --- a/src/dev_deps.ts +++ b/src/dev_deps.ts @@ -1 +1,6 @@ -export { assertEquals } from "https://deno.land/std@0.138.0/testing/asserts.ts"; +export { + assertEquals, + assertRejects, + assertStringIncludes, +} from "https://deno.land/std@0.138.0/testing/asserts.ts"; +export * from "https://deno.land/x/mock_fetch@0.3.0/mod.ts"; diff --git a/src/libraries.ts b/src/libraries.ts new file mode 100644 index 0000000..ae66a8a --- /dev/null +++ b/src/libraries.ts @@ -0,0 +1,19 @@ +export const DENO_SLACK_SDK = "deno_slack_sdk"; +export const DENO_SLACK_BUILDER = "deno_slack_builder"; +export const DENO_SLACK_API = "deno_slack_api"; +export const DENO_SLACK_HOOKS = "deno_slack_hooks"; +export const DENO_SLACK_RUNTIME = "deno_slack_runtime"; + +export const VERSIONS = { + [DENO_SLACK_BUILDER]: "0.0.12", + [DENO_SLACK_RUNTIME]: "0.0.6", + [DENO_SLACK_HOOKS]: "0.0.6", +}; + +export const BUILDER_TAG = `${DENO_SLACK_BUILDER}@${ + VERSIONS[DENO_SLACK_BUILDER] +}`; +export const RUNTIME_TAG = `${DENO_SLACK_RUNTIME}@${ + VERSIONS[DENO_SLACK_RUNTIME] +}`; +export const HOOKS_TAG = `${DENO_SLACK_HOOKS}@${VERSIONS[DENO_SLACK_HOOKS]}`; diff --git a/src/mod.ts b/src/mod.ts index d49f40f..ea41b27 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,8 +1,6 @@ +import { BUILDER_TAG, HOOKS_TAG, RUNTIME_TAG } from "./libraries.ts"; import { getStartHookAdditionalDenoFlags } from "./flags.ts"; -export const BUILDER_TAG = "deno_slack_builder@0.0.12"; -export const RUNTIME_TAG = "deno_slack_runtime@0.0.6"; - export const projectScripts = (args: string[]) => { const startHookFlags = getStartHookAdditionalDenoFlags(args); return { @@ -14,6 +12,8 @@ export const projectScripts = (args: string[]) => { `deno run -q --config=deno.jsonc --allow-read --allow-write --allow-net --allow-run https://deno.land/x/${BUILDER_TAG}/mod.ts`, "start": `deno run -q --config=deno.jsonc --allow-read --allow-net ${startHookFlags} https://deno.land/x/${RUNTIME_TAG}/local-run.ts`, + "check-update": + `deno run -q --config=deno.jsonc --allow-read --allow-net https://deno.land/x/${HOOKS_TAG}/check-update.ts`, }, "config": { "watch": { diff --git a/src/tests/check-update.test.ts b/src/tests/check-update.test.ts new file mode 100644 index 0000000..8b2cb3c --- /dev/null +++ b/src/tests/check-update.test.ts @@ -0,0 +1,67 @@ +import { assertEquals, assertRejects } from "../dev_deps.ts"; +import * as mf from "../dev_deps.ts"; +import { extractVersion, fetchLatestModuleVersion } from "../check-update.ts"; + +Deno.test("check-update hook tests", async (t) => { + await t.step("extractVersion method", async (evT) => { + await evT.step( + "if version string does not contain an '@' then return empty", + () => { + assertEquals( + extractVersion("bat country"), + "", + "empty string not returned", + ); + }, + ); + + await evT.step( + "if version string contains a slash after the '@' should return just the version", + () => { + assertEquals( + extractVersion("https://deon.land/x/slack_goodise@0.1.0/mod.ts"), + "0.1.0", + "version not returned", + ); + }, + ); + + await evT.step( + "if version string does not contain a slash after the '@' should return just the version", + () => { + assertEquals( + extractVersion("https://deon.land/x/slack_goodise@0.1.0"), + "0.1.0", + "version not returned", + ); + }, + ); + }); + await t.step("fetchLatestModuleVersion method", async (evT) => { + mf.install(); // mock out calls to fetch + await evT.step( + "should throw if location header is not returned", + async () => { + mf.mock("GET@/x/slack", (_req: Request) => { + return new Response(null, { headers: {} }); + }); + await assertRejects(async () => { + return await fetchLatestModuleVersion("slack"); + }); + }, + ); + await evT.step( + "should return version extracted from location header", + async () => { + mf.mock("GET@/x/slack", (_req: Request) => { + return new Response(null, { + headers: { location: "/x/slack@0.1.1" }, + }); + }); + const version = await fetchLatestModuleVersion("slack"); + assertEquals(version, "0.1.1", "inocrrect version returned"); + }, + ); + mf.uninstall(); + }); +});