Skip to content

Commit

Permalink
[HERMES-1721] Add check-update hook functionality (#10)
Browse files Browse the repository at this point in the history
  • Loading branch information
misscoded authored May 24, 2022
1 parent 5142263 commit 31ecc1a
Show file tree
Hide file tree
Showing 8 changed files with 365 additions and 8 deletions.
47 changes: 47 additions & 0 deletions .github/maintainers_guide.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions .github/workflows/deno.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
.DS_Store
.coverage
.coverage
.DS_Store
219 changes: 219 additions & 0 deletions src/check-update.ts
Original file line number Diff line number Diff line change
@@ -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<VersionMap> {
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<string> {
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()));
}
7 changes: 6 additions & 1 deletion src/dev_deps.ts
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
export { assertEquals } from "https://deno.land/[email protected]/testing/asserts.ts";
export {
assertEquals,
assertRejects,
assertStringIncludes,
} from "https://deno.land/[email protected]/testing/asserts.ts";
export * from "https://deno.land/x/[email protected]/mod.ts";
19 changes: 19 additions & 0 deletions src/libraries.ts
Original file line number Diff line number Diff line change
@@ -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]}`;
6 changes: 3 additions & 3 deletions src/mod.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { BUILDER_TAG, HOOKS_TAG, RUNTIME_TAG } from "./libraries.ts";
import { getStartHookAdditionalDenoFlags } from "./flags.ts";

export const BUILDER_TAG = "[email protected]";
export const RUNTIME_TAG = "[email protected]";

export const projectScripts = (args: string[]) => {
const startHookFlags = getStartHookAdditionalDenoFlags(args);
return {
Expand All @@ -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": {
Expand Down
67 changes: 67 additions & 0 deletions src/tests/check-update.test.ts
Original file line number Diff line number Diff line change
@@ -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/[email protected]/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/[email protected]"),
"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/[email protected]" },
});
});
const version = await fetchLatestModuleVersion("slack");
assertEquals(version, "0.1.1", "inocrrect version returned");
},
);
mf.uninstall();
});
});

0 comments on commit 31ecc1a

Please sign in to comment.