diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..f6f4cea --- /dev/null +++ b/.env.sample @@ -0,0 +1,3 @@ +GH_TOKEN= +port=3000 +XDG_CONFIG_HOME= diff --git a/README.md b/README.md index 3aa93fb..8c02260 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,13 @@ [![npm badge][npm-badge-png]][package-url] + CLI to list all repos a user has access to, and report on their configuration in aggregate. # Installation - `npm install` to install all dependencies -- create `.env` file and initialize `GH_TOKEN` or `GITHUB_TOKEN` (in order of precedence) with your Github token +- run `cp .env.sample .env` and initialize GH_TOKEN or GITHUB_TOKEN (in order of precedence) with your Github token, set the port on which the gui will be served (default=3000) in the newly generated `.env` file. # Usage (for public) diff --git a/bin/commands/detail.js b/bin/commands/detail.js index dfc74ff..46454d9 100644 --- a/bin/commands/detail.js +++ b/bin/commands/detail.js @@ -37,6 +37,9 @@ module.exports.builder = (yargs) => { describe: 'Show available metrics', type: 'boolean', }) + .option('gui', { + describe: 'Show output in the form of a webpage', + }) .help('help') .strict(); diff --git a/package.json b/package.json index e387f69..019b93f 100644 --- a/package.json +++ b/package.json @@ -29,13 +29,18 @@ "private": false, "dependencies": { "@octokit/graphql": "^4.8.0", + "axios": "^0.24.0", "cli-table": "^0.3.11", "colors": "^1.4.0", "dotenv": "^10.0.0", + "express": "^4.17.1", + "http-status-codes": "^2.2.0", "jsonschema": "^1.4.0", "minimatch": "^3.0.4", "mkdirp": "^1.0.4", - "yargs": "^17.3.0" + "open": "^8.3.0", + "yargs": "^17.3.0", + "yargs-parser": "^20.2.9" }, "devDependencies": { "@ljharb/eslint-config": "^20.0.0", diff --git a/src/commands/detail.js b/src/commands/detail.js index b0b0318..f6ddecc 100644 --- a/src/commands/detail.js +++ b/src/commands/detail.js @@ -8,6 +8,7 @@ const { generateDetailTable, } = require('../utils'); +const server = require('../server/app.js'); const getMetrics = require('../metrics'); const Metrics = require('../../config/metrics.js'); @@ -124,7 +125,12 @@ module.exports = async function detail(flags) { }); if (table) { - console.log(String(table)); + if (flags.gui) { + server(); + } else { + console.log(String(table)); + } + } printAPIPoints(points); diff --git a/src/server/app.js b/src/server/app.js new file mode 100644 index 0000000..0dcd475 --- /dev/null +++ b/src/server/app.js @@ -0,0 +1,20 @@ +'use strict'; + +const express = require('express'); +const { executeCommand } = require('./controllers'); +const path = require('path'); +const open = require('open'); + +module.exports = function server() { + const app = express(); + const { port } = process.env; + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + app.use('/', express.static(path.join(__dirname, '../static'))); + app.post('/command', executeCommand); + app.listen(port, () => { + console.log(`Api is listening on port ${port}`); + console.log(`View the gui on: http://localhost:${port}/`); + }); + open(`http://localhost:${port}/`); +}; diff --git a/src/server/controllers.js b/src/server/controllers.js new file mode 100644 index 0000000..4d5cdb7 --- /dev/null +++ b/src/server/controllers.js @@ -0,0 +1,194 @@ +/* eslint-disable no-magic-numbers */ + +'use strict'; + +const { StatusCodes } = require('http-status-codes'); + +const parse = require('yargs-parser'); +const { + listMetrics, + getRepositories, + generateDetailTable, + generateGui, +} = require('../utils'); + +const getMetrics = require('../metrics'); + +// Metric names and their extraction method to be used on the query result (Order is preserved) +const metricNames = [ + 'Repository', + 'isFork', + 'Access', + 'IssuesEnabled', + 'ProjectsEnabled', + 'WikiEnabled', + 'AllowsForking', + 'Archived', + 'AutoMergeAllowed', + 'BlankIssuesEnabled', + 'SecurityPolicyEnabled', + 'License', + 'MergeStrategies', + 'DeleteOnMerge', + 'HasStarred', + 'Subscription', + 'DefBranch', + 'AllowsForcePushes', + 'AllowsDeletions', + 'DismissesStaleReviews', + 'ReqApprovingReviewCount', + 'ReqApprovingReviews', + 'ReqCodeOwnerReviews', + 'ReqConversationResolution', + 'isPrivate', +]; + +const generateQuery = (endCursor, { + f, +}) => { + let showForks = false; + let showSources = true; + let showPrivate = false; + let showPublic = true; + if (Array.isArray(f)) { + showForks = f.includes('forks'); + showSources = f.includes('sources'); + showPrivate = f.includes('private'); + showPublic = f.includes('public'); + } + return ( + `query { + viewer { + repositories( + first: 100 + affiliations: [OWNER, ORGANIZATION_MEMBER, COLLABORATOR] + ${endCursor ? `after: "${endCursor}"` : ''} + ${showForks === showSources ? '' : showForks ? 'isFork: true' : 'isFork: false'} + ${showPrivate === showPublic ? '' : showPublic ? 'privacy: PUBLIC' : 'privacy: PRIVATE'} + ) { + totalCount + pageInfo { + endCursor + hasNextPage + } + nodes { + name + nameWithOwner + defaultBranchRef { + name + branchProtectionRule { + allowsForcePushes + allowsDeletions + dismissesStaleReviews + requiredApprovingReviewCount + requiresApprovingReviews + requiresCodeOwnerReviews + requiresConversationResolution + restrictsPushes + } + } + deleteBranchOnMerge + hasIssuesEnabled + hasProjectsEnabled + hasWikiEnabled + forkingAllowed + isArchived + autoMergeAllowed + isBlankIssuesEnabled + isFork + isPrivate + isSecurityPolicyEnabled + isTemplate + licenseInfo { + name + } + mergeCommitAllowed + owner { + login + } + rebaseMergeAllowed + squashMergeAllowed + createdAt + updatedAt + pushedAt + viewerHasStarred + viewerPermission + viewerSubscription + } + } + } + rateLimit { + cost + remaining + } +} +`); +}; + +/* + * DOUBT: Copy-Pasted this function here as I was having issues with importing the function + * from src/commands/detail.js + */ +const detail = async (flags) => { + if (flags.m) { + return listMetrics(getMetrics(metricNames)); + } + let metrics; + if (flags.p?.length > 0) { + metrics = getMetrics([ + 'Repository', + 'isFork', + 'isPrivate', + ...metricNames.filter((name) => flags.p.includes(name)), + ]); + } else { + metrics = getMetrics(metricNames); + } + + // Additional Filter on repos + let filter; + if (flags.f?.length === 1 && flags.f[0] === 'templates') { + filter = (repo) => repo.isTemplate; + } + // Get all repositories + const { repositories } = await getRepositories(generateQuery, flags, filter); + if (!flags.s) { + repositories.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + } + // Generate output table + const table = generateDetailTable(metrics, repositories, { + actual: flags.actual, all: flags.all, goodness: flags.goodness, sort: flags.s, unactionable: flags.unactionable, + }); + + return table || null; +}; + +const executeCommand = async (req, res) => { + const { command } = req.body; + const argv = parse(command); + argv.goodness = true; + + if (!argv.token) { + // token not present, so check positional argument to check if GH_TOKEN or GITHUB_TOKEN is present + argv._.forEach((element) => { + const [key, val] = element.split('='); + if (key === 'GH_TOKEN' || key === 'GITHUB_TOKEN') { + argv.token = val; + } + }); + if (!argv.token) { + // no token provided in frontend, hence return 403 + return res.status(StatusCodes.FORBIDDEN).json({ + msg: 'env variable GH_TOKEN or GITHUB_TOKEN, or `--token` argument, not found.', + success: 0, + }); + } + } + let output = await detail(argv); + output = generateGui(output); + return res.status(StatusCodes.OK).json({ output }); +}; + +module.exports = { + executeCommand, +}; diff --git a/src/static/index.html b/src/static/index.html new file mode 100644 index 0000000..c73c283 --- /dev/null +++ b/src/static/index.html @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + +
+
Repo Report!
+
+
Enter Command and press Enter!
+
+
+
+
+
+
+
+
+
+
+ + +
+ + + +
+
+
+ + + + diff --git a/src/static/style.css b/src/static/style.css new file mode 100644 index 0000000..3e7ed4e --- /dev/null +++ b/src/static/style.css @@ -0,0 +1,205 @@ +body { + margin: 0; +} + +* { + font-family: 'Inter', sans-serif; +} + +.container { + display: flex; + flex-direction: column; + height: 100vh; + padding: 0 5vw 0 5vw; +} + +.title { + display: flex; + justify-content: center; + align-items: center; + height: auto; + font-weight: bold; + font-size: 2.25rem; + height: 10vh; +} + +.status { + display: flex; + width: 50%; + align-items: center; +} + +.status-error { + display: flex; + color: red; + align-items: center; +} + +.status-hide { + display: none; +} + +.loader { + display: flex; + align-items: center; + justify-content: center; + position: relative; + width: 40px; + height: 40px; +} + +.loader div { + box-sizing: border-box; + display: block; + position: absolute; + width: 10px; + height: 10px; + margin: 0px; + border: 1px solid #fff; + border-radius: 50%; + animation: loader 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; + border-color: #ff4e3d transparent transparent transparent; +} + +.loader div:nth-child(1) { + animation-delay: -0.45s; +} + +.loader div:nth-child(2) { + animation-delay: -0.3s; +} + +.loader div:nth-child(3) { + animation-delay: -0.15s; +} + +@keyframes loader { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.td-container { + display: flex; + justify-content: center; +} + +.instructions { + display: grid; + grid-template-columns: 1fr 1fr; + font-size: 1rem; + width: 100%; + height: 5vh; +} + +.command { + width: 35vw; + font-size: 0.75rem; + line-height: 1rem; + color: rgba(39, 174, 96, 1); + border-width: 1px; + border-radius: 0.5rem; + background-color: rgba(51, 51, 51, 1); + margin-top: 0.75rem; + padding-left: 1rem; + height: 5vh; +} + +.table-container { + height: 70vh; + max-width: 90vw; + overflow: scroll; + margin-top: 24px; + border: 1px solid #d1cfd7; +} + +.table { + --tw-border-opacity: 1; + position: relative; + border-collapse: collapse; +} + +.table-head { + background-color: #f7f7fa; +} + +.circle-green { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #4cd964; + display: flex; + justify-content: center; + align-items: center; +} + +.circle-red { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #ff4e3d; + display: flex; + justify-content: center; + align-items: center; +} + +th { + padding: 24px; + text-align: center; +} + +td { + border: 1px solid #d1cfd7; +} + +tr:nth-child(even) { + background-color: #f7f7fa; +} + +td:hover { + background-color: #e0e0e0; +} + +th { + /* Top Row */ + background-color: #d1cfd7; + position: sticky; + top: 0px; + z-index: 9; +} + +th:first-of-type { + /* Top Left Cell */ + left: 0; + z-index: 10; +} + +tbody tr td:first-of-type { + /* Left Column */ + background-color: #d1cfd7; + position: sticky; + max-width: 20vw; + overflow-wrap: break-word; + left: 0px; + text-align: left; + padding: 16px; + z-index: 5; +} + +@media (max-width: 768px) { + .title { + font-size: 1.75rem; + } + + .instructions { + font-size: 0.75rem; + } + + .command { + /* font-size: 0.25rem; */ + } +} diff --git a/src/utils.js b/src/utils.js index dae8fe5..4763d38 100644 --- a/src/utils.js +++ b/src/utils.js @@ -349,9 +349,31 @@ const generateDetailTable = (metrics, rowData, { return table; }; +const generateGui = (table) => { + const metrics = table.options.head; + const repos = []; + const rows = []; + + for (let i = 0; i < table.length; i++) { + rows.push(table[i] + .filter((e, j) => j !== 0) + .map((e) => (e === symbols.error ? 0 : 1))); + repos.push(table[i] + .filter((e, j) => j === 0)); + } + + metrics.shift(); + return { + metrics, + repos, + rows, + }; +}; + module.exports = { dumpCache, generateDetailTable, + generateGui, getDiffSymbol, getRepositories, isConfigValid,