(feat) complete app
This commit is contained in:
parent
7d626ee276
commit
dd8a5bfa8d
11
.eslintrc
Normal file
11
.eslintrc
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"prettier"
|
||||||
|
]
|
||||||
|
}
|
||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
|
||||||
0
.prettierrc.json
Normal file
0
.prettierrc.json
Normal file
4408
package-lock.json
generated
Normal file
4408
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "jira-graph",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "run-p copy-resources run-dev",
|
||||||
|
"run-dev": "nodemon src/server.ts",
|
||||||
|
"copy-resources": "cpx src/public/**/* dist/public -w",
|
||||||
|
"prettyprint": "prettier --write .",
|
||||||
|
"build": "tsc --project tsconfig.json"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.1",
|
||||||
|
"axios": "^0.27.2",
|
||||||
|
"typescript": "^4.7.4",
|
||||||
|
"lodash.snakecase": "^4.1.1",
|
||||||
|
"typedi": "^0.10.0",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"chart.js": "^3.8.2",
|
||||||
|
"red-agate-svg-canvas": "^0.5.0",
|
||||||
|
"lowdb": "^3.0.0",
|
||||||
|
"serve-favicon": "^2.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node14": "^1.0.3",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"nodemon": "^2.0.19",
|
||||||
|
"eslint": "^8.20.0",
|
||||||
|
"@typescript-eslint/parser": "^5.31.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.31.0",
|
||||||
|
"@types/lodash.snakecase": "^4.1.7",
|
||||||
|
"@types/node": "14.14.31",
|
||||||
|
"@types/express": "^4.17.13",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"cpx": "^1.5.0",
|
||||||
|
"npm-run-all": "^4.1.5",
|
||||||
|
"@types/serve-favicon": "^2.5.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/handlers/api.ts
Normal file
28
src/handlers/api.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { Container } from "typedi";
|
||||||
|
import { HistoryService } from "../service/HistoryService.js";
|
||||||
|
import { ChartService } from "../service/ChartService.js";
|
||||||
|
import { ChartConfiguration } from "chart.js";
|
||||||
|
|
||||||
|
const historyService = Container.get(HistoryService);
|
||||||
|
const chartService = Container.get(ChartService);
|
||||||
|
|
||||||
|
export const apiRouter = express.Router();
|
||||||
|
|
||||||
|
async function getDataChartConfig(): Promise<ChartConfiguration | null> {
|
||||||
|
const historicalData = await historyService.getHistoricalData();
|
||||||
|
return chartService.toChartConfig(historicalData);
|
||||||
|
}
|
||||||
|
|
||||||
|
apiRouter.get("/ping", (req, res) => {
|
||||||
|
return res.send("API alive");
|
||||||
|
});
|
||||||
|
|
||||||
|
apiRouter.get("/history", async (req, res) => {
|
||||||
|
const chartConfig = await getDataChartConfig();
|
||||||
|
if (chartConfig) {
|
||||||
|
return res.json({ chartConfig });
|
||||||
|
} else {
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
|
});
|
||||||
25
src/handlers/appStatus.ts
Normal file
25
src/handlers/appStatus.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { AppConfig, Configuration } from "../util/Configuration.js";
|
||||||
|
import { Container } from "typedi";
|
||||||
|
|
||||||
|
const version = process.env["npm_package_version"] || "snapshot";
|
||||||
|
const secretProps: Array<keyof Configuration> = [
|
||||||
|
"jiraUsername",
|
||||||
|
"jiraPassword",
|
||||||
|
"jiraAccessToken",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function answerPing(req: Request, res: Response) {
|
||||||
|
res.json({ version });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConfigurationHandler() {
|
||||||
|
const config = Container.get(AppConfig);
|
||||||
|
return (req: Request, res: Response) => {
|
||||||
|
const purifiedConfig = Object.assign({}, config.getConfiguration());
|
||||||
|
secretProps.forEach((secretProp) => {
|
||||||
|
purifiedConfig[secretProp] = "***";
|
||||||
|
});
|
||||||
|
res.json(purifiedConfig);
|
||||||
|
};
|
||||||
|
}
|
||||||
11
src/handlers/current.ts
Normal file
11
src/handlers/current.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { Container } from "typedi";
|
||||||
|
import { StatusService } from "../service/StatusService.js";
|
||||||
|
|
||||||
|
export function createCurrentHandler() {
|
||||||
|
const statusService = Container.get(StatusService);
|
||||||
|
return async (req: Request, res: Response) => {
|
||||||
|
const issues = await statusService.getCurrentStats();
|
||||||
|
res.json(issues);
|
||||||
|
};
|
||||||
|
}
|
||||||
87
src/handlers/graph.ts
Normal file
87
src/handlers/graph.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
SvgCanvas,
|
||||||
|
Rect2D,
|
||||||
|
SvgCanvas2DGradient,
|
||||||
|
} from "red-agate-svg-canvas/modules";
|
||||||
|
import * as ChartJs from "chart.js";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
|
// Get the global scope.
|
||||||
|
// If running on a node, "g" points to a "global" object.
|
||||||
|
// When running on the browser, "g" points to the "window" object.
|
||||||
|
const g = Function("return this")();
|
||||||
|
|
||||||
|
// Chart options
|
||||||
|
// https://www.chartjs.org/docs/latest/getting-started/usage.html
|
||||||
|
const labels = ["January", "February", "March", "April", "May", "June"];
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "My First dataset",
|
||||||
|
backgroundColor: "rgb(255, 99, 132)",
|
||||||
|
borderColor: "rgb(255, 99, 132)",
|
||||||
|
data: [0, 10, 5, 2, 20, 30, 45],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: ChartJs.ChartConfiguration = {
|
||||||
|
type: "line",
|
||||||
|
data: data,
|
||||||
|
options: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function draw(): string {
|
||||||
|
// SvgCanvas has a "CanvasRenderingContext2D"-compatible interface.
|
||||||
|
const ctx = new SvgCanvas();
|
||||||
|
|
||||||
|
// SvgCanvas lacks the canvas property.
|
||||||
|
(ctx as any).canvas = {
|
||||||
|
width: 800,
|
||||||
|
height: 400,
|
||||||
|
style: {
|
||||||
|
width: "800px",
|
||||||
|
height: "400px",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// SvgCanvas does not have font glyph information,
|
||||||
|
// so manually set the ratio of (font height / font width).
|
||||||
|
ctx.fontHeightRatio = 2;
|
||||||
|
|
||||||
|
// Chart.js needs a "HTMLCanvasElement"-like interface that has "getContext()" method.
|
||||||
|
// "getContext()" should returns a "CanvasRenderingContext2D"-compatible interface.
|
||||||
|
const el = { getContext: () => ctx };
|
||||||
|
|
||||||
|
// If "devicePixelRatio" is not set, Chart.js get the devicePixelRatio from "window" object.
|
||||||
|
// node.js environment has no window object.
|
||||||
|
config.options!.devicePixelRatio = 1;
|
||||||
|
|
||||||
|
// Disable animations.
|
||||||
|
config.options!.animation = false;
|
||||||
|
config.options!.events = [];
|
||||||
|
config.options!.responsive = false;
|
||||||
|
|
||||||
|
// Chart.js needs the "CanvasGradient" in the global scope.
|
||||||
|
const savedGradient = g.CanvasGradient;
|
||||||
|
g.CanvasGradient = SvgCanvas2DGradient;
|
||||||
|
try {
|
||||||
|
const chart = new ChartJs.Chart(el as any, config);
|
||||||
|
} finally {
|
||||||
|
if (savedGradient) {
|
||||||
|
g.CanvasGradient = savedGradient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Render as SVG.
|
||||||
|
const svgString = ctx.render(new Rect2D(0, 0, 800, 400), "px");
|
||||||
|
console.log(svgString);
|
||||||
|
return svgString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDrawSvgHandler() {
|
||||||
|
return (req: Request, res: Response) => {
|
||||||
|
res.send(draw());
|
||||||
|
};
|
||||||
|
}
|
||||||
4
src/model/Datapoint.ts
Normal file
4
src/model/Datapoint.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface Datapoint {
|
||||||
|
time: Date;
|
||||||
|
remainingEffort: number;
|
||||||
|
}
|
||||||
5
src/model/HistoricalData.ts
Normal file
5
src/model/HistoricalData.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Datapoint } from "./Datapoint.js";
|
||||||
|
|
||||||
|
export interface HistoricalData {
|
||||||
|
dataPoints: Array<Datapoint>;
|
||||||
|
}
|
||||||
8
src/model/Issue.ts
Normal file
8
src/model/Issue.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface Issue {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
fields: {
|
||||||
|
updated: Date;
|
||||||
|
timeestimate: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
6
src/model/IssueOverview.ts
Normal file
6
src/model/IssueOverview.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { Issue } from "./Issue.js";
|
||||||
|
|
||||||
|
export interface IssueOverview {
|
||||||
|
total: number;
|
||||||
|
issues: Array<Issue>;
|
||||||
|
}
|
||||||
6
src/model/Stats.ts
Normal file
6
src/model/Stats.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface Stats {
|
||||||
|
numIssues: number;
|
||||||
|
totalEstimateInS: number;
|
||||||
|
totalEstimateInH: number;
|
||||||
|
totalEstimateInD: number;
|
||||||
|
}
|
||||||
35
src/public/chart.html
Normal file
35
src/public/chart.html
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>JIRA Stats V.2</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<canvas id="myChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="myErrorArea"></div>
|
||||||
|
<script>
|
||||||
|
fetch("/api/history")
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
response.json().then((data) => {
|
||||||
|
if (data) {
|
||||||
|
const chartConfig = data.chartConfig;
|
||||||
|
if (chartConfig) {
|
||||||
|
new Chart(document.getElementById("myChart"), chartConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
document.getElementById(
|
||||||
|
"myErrorArea"
|
||||||
|
).innerHTML = `<strong>Error fetching data: ${error.message} - ${error.stack}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
src/public/favicon.png
Normal file
BIN
src/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
37
src/repository/HistoryRepository.ts
Normal file
37
src/repository/HistoryRepository.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Service } from "typedi";
|
||||||
|
import { Low, JSONFile } from "lowdb";
|
||||||
|
import { Datapoint } from "../model/Datapoint.js";
|
||||||
|
import { join } from "path";
|
||||||
|
import { __dirname } from "../util/Configuration.js";
|
||||||
|
|
||||||
|
type Data = {
|
||||||
|
points: Array<Datapoint>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filename = join(__dirname, "../data/db.json");
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class HistoryRepository {
|
||||||
|
private readonly db: Low<Data>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const adapter = new JSONFile<Data>(filename);
|
||||||
|
this.db = new Low(adapter);
|
||||||
|
this.db.read().then(() => {
|
||||||
|
this.db.data = this.db.data || { points: [] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(): Promise<Array<Datapoint>> {
|
||||||
|
return this.db.data!.points;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(time: Date, remainingEffort: number): Promise<Array<Datapoint>> {
|
||||||
|
this.db.data!.points.push({
|
||||||
|
time,
|
||||||
|
remainingEffort,
|
||||||
|
});
|
||||||
|
await this.db.write();
|
||||||
|
return this.db.data!.points;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/server.ts
Normal file
28
src/server.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import "reflect-metadata";
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
answerPing,
|
||||||
|
createConfigurationHandler,
|
||||||
|
} from "./handlers/appStatus.js";
|
||||||
|
import { Container } from "typedi";
|
||||||
|
import { createCurrentHandler } from "./handlers/current.js";
|
||||||
|
import path from "path";
|
||||||
|
import { apiRouter } from "./handlers/api.js";
|
||||||
|
import { AppConfig, __dirname } from "./util/Configuration.js";
|
||||||
|
import favicon from "serve-favicon";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const config = Container.get(AppConfig);
|
||||||
|
const port = config.getConfiguration().listenPort;
|
||||||
|
|
||||||
|
app.use(favicon(path.join(__dirname, "public", "favicon.png")));
|
||||||
|
app.get("/ping", answerPing);
|
||||||
|
app.get("/config", createConfigurationHandler());
|
||||||
|
app.get("/current", createCurrentHandler());
|
||||||
|
app.use("/api", apiRouter);
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`App listening on port ${port}`);
|
||||||
|
});
|
||||||
48
src/service/ChartService.ts
Normal file
48
src/service/ChartService.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Service } from "typedi";
|
||||||
|
import { AppConfig, Configuration } from "../util/Configuration.js";
|
||||||
|
import { ChartConfiguration, ChartTypeRegistry } from "chart.js";
|
||||||
|
import { HistoricalData } from "../model/HistoricalData.js";
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ChartService {
|
||||||
|
private readonly config: Configuration;
|
||||||
|
|
||||||
|
constructor(configService: AppConfig) {
|
||||||
|
this.config = configService.getConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
toChartConfig(history: HistoricalData): ChartConfiguration<"line"> | null {
|
||||||
|
if (!history.dataPoints.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const labels: Array<Date> = [];
|
||||||
|
const values: Array<number> = [];
|
||||||
|
history.dataPoints.forEach((mapping) => {
|
||||||
|
labels.push(mapping.time);
|
||||||
|
values.push(mapping.remainingEffort);
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: this.config.datasetLabel,
|
||||||
|
backgroundColor: "rgb(255, 99, 132)",
|
||||||
|
borderColor: "rgb(255, 99, 132)",
|
||||||
|
data: values,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const config = {
|
||||||
|
type: "line" as "line",
|
||||||
|
data: data,
|
||||||
|
options: {
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: "time" as "time",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/service/HistoryService.ts
Normal file
25
src/service/HistoryService.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Service } from "typedi";
|
||||||
|
import { HistoricalData } from "../model/HistoricalData.js";
|
||||||
|
import { HistoryRepository } from "../repository/HistoryRepository.js";
|
||||||
|
import { Datapoint } from "../model/Datapoint.js";
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class HistoryService {
|
||||||
|
constructor(private readonly repository: HistoryRepository) {}
|
||||||
|
|
||||||
|
private toHistoricalData(datapoints: Array<Datapoint>): HistoricalData {
|
||||||
|
return {
|
||||||
|
dataPoints: datapoints,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistoricalData(): Promise<HistoricalData> {
|
||||||
|
const history = await this.repository.getHistory();
|
||||||
|
return this.toHistoricalData(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCurrent(time: Date, remainingEffort: number): Promise<number> {
|
||||||
|
const ret = await this.repository.save(time, remainingEffort);
|
||||||
|
return ret.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/service/StatusService.ts
Normal file
75
src/service/StatusService.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Service } from "typedi";
|
||||||
|
import { AppConfig, Configuration } from "../util/Configuration.js";
|
||||||
|
import axios from "axios";
|
||||||
|
import { IssueOverview } from "../model/IssueOverview.js";
|
||||||
|
import { Issue } from "../model/Issue.js";
|
||||||
|
import { Stats } from "../model/Stats.js";
|
||||||
|
import { HistoryService } from "./HistoryService.js";
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class StatusService {
|
||||||
|
private readonly config: Configuration;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
configService: AppConfig,
|
||||||
|
private readonly historyService: HistoryService
|
||||||
|
) {
|
||||||
|
this.config = configService.getConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultHeaders() {
|
||||||
|
return {
|
||||||
|
headers: { Authorization: `Bearer ${this.config.jiraAccessToken}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSearchUrl(): Promise<string> {
|
||||||
|
const filterUrl = `${this.config.jiraUrl}filter/${this.config.jiraFilter}`;
|
||||||
|
const response = await axios.get(filterUrl, this.getDefaultHeaders());
|
||||||
|
const ret = response.data["searchUrl"];
|
||||||
|
if (!ret || typeof ret !== "string") {
|
||||||
|
throw new Error(`Could not read searchUrl, received: ${ret}`);
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getIssues(): Promise<IssueOverview> {
|
||||||
|
const searchUrl = await this.getSearchUrl();
|
||||||
|
const ret: Array<Issue> = [];
|
||||||
|
let currentStartItem = 0;
|
||||||
|
let total;
|
||||||
|
let maxResults;
|
||||||
|
do {
|
||||||
|
let searchUrlWithPage = `${searchUrl}&startAt=${currentStartItem}`;
|
||||||
|
const response = await axios.get(
|
||||||
|
searchUrlWithPage,
|
||||||
|
this.getDefaultHeaders()
|
||||||
|
);
|
||||||
|
const data = response.data;
|
||||||
|
total = data.total;
|
||||||
|
maxResults = data.maxResults;
|
||||||
|
currentStartItem += maxResults;
|
||||||
|
ret.push(...data.issues);
|
||||||
|
} while (currentStartItem <= total);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
issues: ret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentStats(): Promise<Stats> {
|
||||||
|
const issues = await this.getIssues();
|
||||||
|
const totalEstimateInS = issues.issues.reduce((sum, issue) => {
|
||||||
|
return sum + issue.fields.timeestimate;
|
||||||
|
}, 0);
|
||||||
|
const totalEstimateInH = totalEstimateInS / 3600;
|
||||||
|
const totalEstimateInD = totalEstimateInS / 3600 / 8;
|
||||||
|
await this.historyService.saveCurrent(new Date(), totalEstimateInD);
|
||||||
|
return {
|
||||||
|
numIssues: issues.total,
|
||||||
|
totalEstimateInS,
|
||||||
|
totalEstimateInH,
|
||||||
|
totalEstimateInD,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/util/Configuration.ts
Normal file
80
src/util/Configuration.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import * as process from "process";
|
||||||
|
import snakecase from "lodash.snakecase";
|
||||||
|
import { Service } from "typedi";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
export interface Configuration {
|
||||||
|
jiraUrl: string;
|
||||||
|
jiraFilter: string;
|
||||||
|
dbType: string;
|
||||||
|
dbUrl?: string;
|
||||||
|
listenPort: string;
|
||||||
|
jiraUsername: string;
|
||||||
|
jiraPassword: string;
|
||||||
|
jiraAccessToken: string;
|
||||||
|
datasetLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_DEFAULTS: Configuration = {
|
||||||
|
jiraUrl: "https://www.exxcellent.de/jira/rest/api/latest/",
|
||||||
|
jiraFilter: "55931",
|
||||||
|
dbType: "local",
|
||||||
|
listenPort: "3000",
|
||||||
|
dbUrl: "",
|
||||||
|
jiraUsername: "pblaesi",
|
||||||
|
jiraPassword: "mySecretJiraPassword",
|
||||||
|
jiraAccessToken: "MzA4MzE5MDc3NjUxOqEVKdb9imjQsdxSoyBxegQQlTfN",
|
||||||
|
datasetLabel: "Remaining Effort",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConfigOption {
|
||||||
|
key: keyof Configuration;
|
||||||
|
defaultValue?: string;
|
||||||
|
envVarName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEnvVar(key: string): string {
|
||||||
|
return snakecase(key).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toConfiguration(opts: ConfigOption[]): Configuration {
|
||||||
|
const configBuilder: Configuration = Object.assign({}, CONFIG_DEFAULTS);
|
||||||
|
opts.forEach((option) => {
|
||||||
|
const effectiveEnvVarName = option.envVarName || toEnvVar(option.key);
|
||||||
|
const valueFromEnv = process.env[effectiveEnvVarName];
|
||||||
|
const value = valueFromEnv || option.defaultValue;
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
configBuilder[option.key] = value;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
configBuilder[option.key] === null ||
|
||||||
|
configBuilder[option.key] === undefined
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not determine value of option ${option.key} using environment variable name ${effectiveEnvVarName}; no default given`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return configBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguration() {
|
||||||
|
const options: Array<ConfigOption> = [
|
||||||
|
{ key: "jiraUrl" },
|
||||||
|
{ key: "jiraFilter" },
|
||||||
|
{ key: "dbType", defaultValue: "local" },
|
||||||
|
{ key: "dbUrl" },
|
||||||
|
];
|
||||||
|
return toConfiguration(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class AppConfig {
|
||||||
|
getConfiguration(): Configuration {
|
||||||
|
return getConfiguration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since we're in "util" subfolder, basedir is one level up
|
||||||
|
export const __dirname = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node14/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"preserveConstEnums": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ES2020",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"lib": ["ES2020"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "**/*.test.ts"],
|
||||||
|
"ts-node": {
|
||||||
|
"esm": true,
|
||||||
|
"experimentalSpecifierResolution": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user