Skip to content

Commit

Permalink
Merge pull request #2 from Unleash/feat/initial-setup
Browse files Browse the repository at this point in the history
feat: initial feature and setup
  • Loading branch information
daveleek authored Feb 12, 2024
2 parents 6ed5fce + f3c514e commit e7483a7
Show file tree
Hide file tree
Showing 13 changed files with 3,893 additions and 1 deletion.
19 changes: 19 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# editorconfig.org
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

[*.json]
indent_size = 2

[*.{yaml,yml}]
indent_size = 2
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ out

# Nuxt.js build / generate output
.nuxt
dist
build

# Gatsby files
.cache/
Expand Down
38 changes: 38 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: 'Unleash Feature Flags'
description: 'Lets you use the getunleash.io feature flags management solution in GitHub Actions'
inputs:
app-name:
description: 'The application name for the GitHub Action as you want it to appear in Unleash metrics'
type: 'string'
required: true
api-key:
description: 'The frontend API token to use with Unleash'
type: 'string'
required: true
url:
description: 'URL to Unleash Edge or frontend API'
type: 'string'
required: true
environment:
description: 'The environment for which to evaluate the feature flags'
type: 'string'
required: false
default: 'default'
is-enabled:
description: 'Newline-separated list of feature flag names to evaluate'
type: 'string'
required: false
get-variant:
description: 'Newline-separated list of feature flag names to get variants for'
type: 'string'
required: false
context:
description: 'Multiline list of key=value pairs that will be string split on ='
type: 'string'
required: false
outputs:
the-value:
description: 'What we found in the input'
runs:
using: 'node20'
main: 'dist/index.js'
1 change: 1 addition & 0 deletions babel.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = {presets: ['@babel/preset-env']}
7 changes: 7 additions & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
transform: {
'^.+\\.(ts|tsx)?$': 'ts-jest',
'^.+\\.(js|jsx)$': 'babel-jest'
}
};
45 changes: 45 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "unleash-action",
"version": "1.0.0",
"description": "Unleash integration for GitHub workflows",
"main": "./dist/index.js",
"repository": "https://github.com/Unleash/unleash-action.git",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"build": "tsc",
"release": "ncc build src/index.ts --license licenses.txt -o dist",
"test": "jest"
},
"dependencies": {
"@actions/core": "^1.10.0",
"@actions/github": "^5.1.1",
"@types/node": "^20.5.7",
"node": "^20.5.1",
"node-fetch": "^3.3.2",
"unleash-proxy-client": "^2.5.0"
},
"devDependencies": {
"@babel/preset-env": "^7.22.14",
"@vercel/ncc": "^0.36.1",
"babel-jest": "^29.6.4",
"jest": "^29.6.4",
"prettier": "^3.0.3",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
},
"prettier": {
"proseWrap": "never",
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "all",
"overrides": [
{
"files": "*.{json,yaml,yml,md}",
"options": {
"tabWidth": 2
}
}
]
}
}
3 changes: 3 additions & 0 deletions src/fetch.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
fetch: fetch
};
30 changes: 30 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createUnleashAction } from './unleash-action';
import { getInput, getMultilineInput, setOutput } from '@actions/core';

const appName = getInput('app-name');
const url = getInput('url');
const clientKey = getInput('api-key');
const environment = getInput('environment');

const context: Record<string, string> = {};
const contextLines = getMultilineInput('context');
contextLines?.forEach((l) => {
let keyVal = l.split('=');
context[keyVal[0]] = keyVal[1];
});

const features = getMultilineInput('is-enabled');
const variants = getMultilineInput('get-variant');

createUnleashAction({
url: url,
clientKey: clientKey,
appName: appName,
environment: environment,
context: context,
features: features,
variants: variants,
setResult: setOutput,
}).then(() => {
console.log('Done!');
});
120 changes: 120 additions & 0 deletions src/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { fetch } from './fetch.cjs';

interface MetricsOptions {
headerName: string;
appName: string;
url: string;
clientKey: string;
}

interface VariantBucket {
[s: string]: number;
}

interface Bucket {
start: Date;
stop: Date | null;
toggles: {
[s: string]: { yes: number; no: number; variants: VariantBucket };
};
}

interface Payload {
bucket: Bucket;
appName: string;
instanceId: string;
}
export class Metrics {
private appName: string;
private url: string;
private clientKey: string;
private headerName: string;
private bucket: Bucket;

constructor(options: MetricsOptions) {
this.appName = options.appName;
this.url = options.url;
this.clientKey = options.clientKey;
this.headerName = options.headerName;
this.bucket = this.createEmptyBucket();
}

private createEmptyBucket(): Bucket {
return {
start: new Date(),
stop: null,
toggles: {},
};
}

private getPayload(): Payload {
const bucket = { ...this.bucket, stop: new Date() };
this.bucket = this.createEmptyBucket();

return {
bucket,
appName: this.appName,
instanceId: 'workflow',
};
}

private getHeaders() {
const headers = {
[this.headerName]: this.clientKey,
Accept: 'application/json',
'Content-Type': 'application/json',
};

return headers;
}

public async sendMetrics(): Promise<void> {
const url = `${this.url}/client/metrics`;
const payload = this.getPayload();

if (this.bucketIsEmpty(payload)) {
return;
}

try {
await fetch(url, {
method: 'POST',
body: JSON.stringify(payload),
headers: this.getHeaders(),
});
} catch (e) {
console.error('Unleash: unable to send feature metrics', e);
}
}

private assertBucket(name: string) {
if (!this.bucket.toggles[name]) {
this.bucket.toggles[name] = {
yes: 0,
no: 0,
variants: {},
};
}
}

private bucketIsEmpty(payload: Payload) {
return Object.keys(payload.bucket.toggles).length === 0;
}

public async count(featureName: string, enabled: boolean): Promise<void> {
this.assertBucket(featureName);
this.bucket.toggles[featureName][enabled ? 'yes' : 'no']++;
}

public async countVariant(
featureName: string,
variant: string,
): Promise<void> {
this.assertBucket(featureName);
if (this.bucket.toggles[featureName].variants[variant]) {
this.bucket.toggles[featureName].variants[variant] += 1;
} else {
this.bucket.toggles[featureName].variants[variant] = 1;
}
}
}
115 changes: 115 additions & 0 deletions src/unleash-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { UnleashClient } from 'unleash-proxy-client';
import { Metrics } from './metrics';

interface ICreateUnleashActionOptions {
url: string;
clientKey: string;
appName: string;
environment: string;
context: Record<string, string>;
features?: string[];
variants?: string[];
setResult: (name: string, value: any) => void;
}

interface IUnleashActionOptions extends ICreateUnleashActionOptions {
client: UnleashClient;
metrics: Metrics;
}

export const createUnleashAction = async (
options: ICreateUnleashActionOptions,
): Promise<void> => {
const client = createClient(options);
const metrics = createMetrics(options);
const action = new UnleashAction({ ...options, client, metrics });
await action.run();
await action.end();
};

const createMetrics = (options: ICreateUnleashActionOptions): Metrics => {
return new Metrics({
headerName: 'Authorization',
appName: options.appName,
url: options.url,
clientKey: options.clientKey,
});
};

const createClient = (options: ICreateUnleashActionOptions): UnleashClient => {
return new UnleashClient({
appName: options.appName,
url: options.url,
clientKey: options.clientKey,
environment: options.environment,
refreshInterval: 0,
metricsInterval: 0,
disableMetrics: true,
});
};

export class UnleashAction {
private unleash: UnleashClient;
private metrics: Metrics;
private features: string[];
private variants: string[];
private setResult: (name: string, value: any) => void;

constructor(options: IUnleashActionOptions) {
this.unleash = options.client;
this.metrics = options.metrics;

this.unleash.on('ready', () => {
console.log('Ready!');
});

this.features = options.features || [];
this.variants = options.variants || [];
this.setResult = options.setResult;
}

async run(): Promise<void> {
console.log('starting.');
await this.unleash.start();

console.log('Checking features.');
await this.checkFeatures();

console.log('Checking variants.');
await this.checkVariants();
}

async end(): Promise<void> {
console.log('Sending metrics.');
await this.metrics.sendMetrics();

console.log('Stopping.');
await this.unleash.stop();
}

private async checkFeatures(): Promise<void> {
this.features.forEach((featureName) => {
const isEnabled = this.unleash.isEnabled(featureName);
this.metrics.count(featureName, isEnabled);
this.setResult(featureName, isEnabled);
});
}

private async checkVariants(): Promise<void> {
this.variants.forEach((featureName) => {
const variant = this.unleash.getVariant(featureName);
if (variant.name) {
this.metrics.countVariant(featureName, variant.name);
}
this.metrics.count(featureName, variant.enabled);
this.setResult(featureName, variant.enabled);

if (variant.enabled) {
this.setResult(
`${featureName}_variant`,
variant.payload?.value,
);
}
});
}
}
Loading

0 comments on commit e7483a7

Please sign in to comment.