├── Procfile ├── .browserslistrc ├── .prettierrc ├── babel.config.js ├── .env.example ├── src ├── pages │ ├── ViewPage.vue │ ├── ViewLocalPanelPage.vue │ ├── localPanelSchema.json │ ├── IndexPage.vue │ └── ManageLocalPanelsPage.vue ├── store │ ├── index.js │ └── localPanel.js ├── custom.scss ├── variables.scss ├── components │ ├── LoginDialog.vue │ ├── GanttChangePreviewDialog.vue │ ├── Nav.vue │ └── GanttView.vue ├── main.js ├── App.vue ├── utils.js └── plugins │ └── octoclient.js ├── .eslintrc.js ├── vue.config.js ├── server.js ├── public └── index.html ├── LICENSE ├── README.md ├── package.json ├── .gitignore └── api.js /Procfile: -------------------------------------------------------------------------------- 1 | web: node server.js 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | CLIENT_ID=YOUR_CLIENT_ID 2 | CLIENT_SECRET=YOUR_CLIENT_SECRET 3 | SERVER_HOST=http://localhost:5000 4 | SESSION_SECRET=YOUR_SESSION_SECRET 5 | -------------------------------------------------------------------------------- /src/pages/ViewPage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /src/pages/ViewLocalPanelPage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import VuexPersistence from 'vuex-persist'; 4 | import localPanel from './localPanel'; 5 | 6 | Vue.use(Vuex); 7 | 8 | const store = new Vuex.Store({ 9 | modules: { 10 | localPanel, 11 | }, 12 | plugins: [ 13 | new VuexPersistence({ 14 | modules: ['localPanel'], 15 | }).plugin, 16 | ], 17 | }); 18 | 19 | export default store; 20 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | 'plugin:prettier/recommended', 9 | 'eslint:recommended', 10 | ], 11 | parserOptions: { 12 | parser: 'babel-eslint', 13 | }, 14 | rules: { 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/custom.scss: -------------------------------------------------------------------------------- 1 | @import './variables.scss'; 2 | @import '~bulma'; 3 | @import '~buefy/src/scss/buefy'; 4 | 5 | .modal-card { 6 | box-shadow: 0 5px 20px rgba(0, 0, 0, 0.05); 7 | } 8 | 9 | .modal-card-head, 10 | .modal-card-foot { 11 | background: #fff; 12 | border: 0; 13 | } 14 | 15 | .modal-card-head { 16 | padding-bottom: 0; 17 | } 18 | 19 | .modal-card-foot { 20 | padding-top: 0; 21 | } 22 | 23 | html, 24 | body { 25 | height: 100%; 26 | overscroll-behavior-x: none; 27 | } 28 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const serveApi = require('./api'); 3 | 4 | module.exports = { 5 | lintOnSave: false, 6 | devServer: { 7 | before: serveApi, 8 | port: 5000, 9 | }, 10 | pluginOptions: { 11 | webpackBundleAnalyzer: { 12 | openAnalyzer: false, 13 | }, 14 | }, 15 | configureWebpack: { 16 | plugins: [ 17 | new webpack.IgnorePlugin({ 18 | resourceRegExp: /^\.\/locale$/, 19 | contextRegExp: /moment$/, 20 | }), 21 | ], 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const express = require('express'); 4 | const path = require('path'); 5 | const compression = require('compression'); 6 | const logger = require('signale'); 7 | const serveApi = require('./api'); 8 | 9 | const app = express(); 10 | 11 | app.use(compression()); 12 | 13 | function serveIndex(req, res) { 14 | res.sendFile(path.join(__dirname, '/dist/index.html')); 15 | } 16 | 17 | app.use(express.static(path.join(__dirname, 'dist'))); 18 | app.get('/view/*', serveIndex); 19 | app.get('/local_panel', serveIndex); 20 | app.get('/local_panel/*', serveIndex); 21 | 22 | serveApi(app); 23 | 24 | const PORT = process.env.PORT || 5000; 25 | app.listen(PORT, () => logger.success(`Listening on ${PORT}`)); 26 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | GanttViewer 9 | 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/pages/localPanelSchema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string" 7 | }, 8 | "name": { 9 | "type": "string" 10 | }, 11 | "projects": { 12 | "type": "object", 13 | "patternProperties": { 14 | ".{1,}": { 15 | "type": "object", 16 | "properties": { 17 | "id": { 18 | "type": "string" 19 | }, 20 | "url": { 21 | "type": "string" 22 | }, 23 | "name": { 24 | "type": "string" 25 | } 26 | }, 27 | "required": ["id", "url", "name"] 28 | } 29 | } 30 | } 31 | }, 32 | "required": ["id", "name", "projects"] 33 | } 34 | -------------------------------------------------------------------------------- /src/variables.scss: -------------------------------------------------------------------------------- 1 | @import '~bulma/sass/utilities/_all'; 2 | 3 | $primary: #4894ff; 4 | $primary-invert: findColorInvert($primary); 5 | 6 | $colors: ( 7 | 'white': ( 8 | $white, 9 | $black, 10 | ), 11 | 'black': ( 12 | $black, 13 | $white, 14 | ), 15 | 'light': ( 16 | $light, 17 | $light-invert, 18 | ), 19 | 'dark': ( 20 | $dark, 21 | $dark-invert, 22 | ), 23 | 'primary': ( 24 | $primary, 25 | $primary-invert, 26 | ), 27 | 'info': ( 28 | $info, 29 | $info-invert, 30 | ), 31 | 'success': ( 32 | $success, 33 | $success-invert, 34 | ), 35 | 'warning': ( 36 | $warning, 37 | $warning-invert, 38 | ), 39 | 'danger': ( 40 | $danger, 41 | $danger-invert, 42 | ), 43 | ); 44 | 45 | $modal-background-background-color: rgba(#f0f0f0, 0.8); 46 | -------------------------------------------------------------------------------- /src/components/LoginDialog.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 38 | -------------------------------------------------------------------------------- /src/store/localPanel.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | export default { 4 | namespaced: true, 5 | state: { 6 | panels: {}, 7 | }, 8 | mutations: { 9 | addPanel(state, { id, name }) { 10 | const panel = { 11 | id, 12 | name, 13 | projects: {}, 14 | }; 15 | Vue.set(state.panels, id, panel); 16 | }, 17 | deletePanel(state, { id }) { 18 | Vue.delete(state.panels, id); 19 | }, 20 | renamePanel(state, { id, name }) { 21 | state.panels[id].name = name; 22 | }, 23 | addProjects(state, { id, projects }) { 24 | projects.forEach(p => { 25 | Vue.set(state.panels[id].projects, p.id, { 26 | id: p.id, 27 | url: p.url, 28 | name: p.name, 29 | }); 30 | }); 31 | }, 32 | deleteProject(state, { id, projectId }) { 33 | Vue.delete(state.panels[id].projects, projectId); 34 | }, 35 | importPanel(state, { panel }) { 36 | const id = panel.id; 37 | Vue.set(state.panels, id, panel); 38 | }, 39 | }, 40 | actions: {}, 41 | getters: {}, 42 | }; 43 | -------------------------------------------------------------------------------- /src/pages/IndexPage.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Wenxuan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # my-gantt-viewer 2 | 3 | Online Gantt Graph based on GitHub Issues 4 | 5 | ## Usage 6 | 7 | 1. Create a project, which will be display as a project in Gantt graph. 8 | 9 | The project must have `EnableGantt` in the description, for example: 10 | 11 | ```markdown 12 | 13 | ``` 14 | 15 | 2. Link issues to project. By default, the issue creation time will be used as task start time 16 | and issue milestone due time will be used as task due time. 17 | 18 | You can add modifiers in the issue body to override the behaviour: 19 | 20 | - `GanttStart: YYYY-MM-DD` 21 | - `GanttDue: YYYY-MM-DD` 22 | - `GanttDuration: Nd`: You can specify duration instead of due date. `d` denotes for days. 23 | - `GanttProgress: N%` 24 | 25 | ## Getting Started Locally 26 | 27 | 1. Copy `.env.example` to `.env`. 28 | 29 | 2. Modify `.env`: 30 | 31 | - Use the client ID and client secret from GitHub OAuth App. 32 | 33 | Note: The Authorization Callback URL of your GitHub OAuth App should be 34 | something like http://localhost:5000/github/callback. 35 | 36 | - Session secret can be generated by: 37 | 38 | ```bash 39 | openssl rand 32 | base64 40 | ``` 41 | 42 | 3. Start by: 43 | 44 | ```bash 45 | npm install 46 | npm run ui:serve 47 | ``` 48 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Buefy from 'buefy'; 3 | import VueRouter from 'vue-router'; 4 | import VueResource from 'vue-resource'; 5 | import VueClipboard from 'vue-clipboard2'; 6 | import vueHeadful from 'vue-headful'; 7 | import store from '@/store'; 8 | import { OctoClientPlugin } from '@/plugins/octoclient'; 9 | import App from '@/App.vue'; 10 | import IndexPage from '@/pages/IndexPage.vue'; 11 | import ViewPage from '@/pages/ViewPage.vue'; 12 | import ManageLocalPanelsPage from '@/pages/ManageLocalPanelsPage.vue'; 13 | import ViewLocalPanelPage from '@/pages/ViewLocalPanelPage.vue'; 14 | 15 | Vue.use(Buefy); 16 | Vue.use(VueRouter); 17 | Vue.use(VueResource); 18 | Vue.use(OctoClientPlugin); 19 | Vue.use(VueClipboard); 20 | Vue.component('vue-headful', vueHeadful); 21 | 22 | Vue.config.productionTip = false; 23 | 24 | const router = new VueRouter({ 25 | mode: 'history', 26 | routes: [ 27 | { 28 | name: 'index', 29 | path: '/', 30 | component: IndexPage, 31 | }, 32 | { 33 | name: 'view', 34 | path: '/view/*', 35 | component: ViewPage, 36 | }, 37 | { 38 | name: 'manage_local_panels', 39 | path: '/local_panel', 40 | component: ManageLocalPanelsPage, 41 | }, 42 | { 43 | name: 'view_local_panel', 44 | path: '/local_panel/:id', 45 | component: ViewLocalPanelPage, 46 | }, 47 | ], 48 | }); 49 | 50 | new Vue({ router, store, render: h => h(App) }).$mount('#app'); 51 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 68 | 69 | 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-gantt-viewer", 3 | "version": "0.0.1", 4 | "engines": { 5 | "node": "14.x" 6 | }, 7 | "main": "server.js", 8 | "scripts": { 9 | "start": "node server.js", 10 | "ui:serve": "vue-cli-service serve", 11 | "ui:build": "vue-cli-service build", 12 | "heroku-postbuild": "npm run ui:build" 13 | }, 14 | "dependencies": { 15 | "@octokit/graphql": "^4.3.1", 16 | "ajv": "^6.12.0", 17 | "axios": "^0.21.1", 18 | "body-parser": "^1.19.0", 19 | "buefy": "^0.8.10", 20 | "color": "^3.1.2", 21 | "common-tags": "^1.8.0", 22 | "compression": "^1.7.4", 23 | "cookie-session": "^1.4.0", 24 | "core-js": "^3.6.4", 25 | "dhtmlx-gantt": "^6.3.7", 26 | "dotenv": "^8.2.0", 27 | "express": "^4.15.2", 28 | "lodash": "^4.17.19", 29 | "moment": "^2.24.0", 30 | "parse-github-url": "^1.0.2", 31 | "randomstring": "^1.1.5", 32 | "signale": "^1.4.0", 33 | "uuid": "^7.0.0", 34 | "vue": "^2.6.11", 35 | "vue-clipboard2": "^0.3.1", 36 | "vue-headful": "^2.1.0", 37 | "vue-resource": "^1.5.1", 38 | "vue-router": "^3.1.5", 39 | "vuex": "^3.1.2", 40 | "vuex-persist": "^2.2.0" 41 | }, 42 | "devDependencies": { 43 | "@vue/cli-plugin-babel": "~4.2.0", 44 | "@vue/cli-plugin-eslint": "~4.2.0", 45 | "@vue/cli-service": "~4.2.0", 46 | "babel-eslint": "^10.0.3", 47 | "eslint": "^6.7.2", 48 | "eslint-plugin-vue": "^6.1.2", 49 | "less": "^3.0.4", 50 | "less-loader": "^5.0.0", 51 | "node-sass": "^4.13.1", 52 | "sass-loader": "^8.0.2", 53 | "vue-cli-plugin-webpack-bundle-analyzer": "~2.0.0", 54 | "vue-template-compiler": "^2.6.11" 55 | }, 56 | "license": "MIT", 57 | "signale": { 58 | "displayScope": true, 59 | "displayBadge": true, 60 | "displayDate": true, 61 | "displayFilename": false, 62 | "displayLabel": true, 63 | "displayTimestamp": true, 64 | "underlineLabel": true, 65 | "underlineMessage": false, 66 | "underlinePrefix": false, 67 | "underlineSuffix": false, 68 | "uppercaseLabel": false 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import gh from 'parse-github-url'; 2 | 3 | export function parseDirectProjectLink(path) { 4 | if (path.trim() === '') { 5 | return null; 6 | } 7 | 8 | { 9 | // Example: https://github.com/orgs/pingcap/projects/8 10 | const m = path.match(/github\.com\/orgs\/([-\w\d\.\_]+)\/projects\/(\d+)/); 11 | if (m) { 12 | return { 13 | type: 'org_project', 14 | org: m[1], 15 | project_num: parseInt(m[2]), 16 | }; 17 | } 18 | } 19 | { 20 | // Example: https://github.com/tikv/tikv/projects/26 21 | const m = path.match( 22 | /github\.com\/([-\w\d\.\_]+)\/([-\w\d\.\_]+)\/projects\/(\d+)/ 23 | ); 24 | if (m) { 25 | return { 26 | type: 'repo_project', 27 | org: m[1], 28 | repo: m[2], 29 | project_num: parseInt(m[3]), 30 | }; 31 | } 32 | } 33 | 34 | return null; 35 | } 36 | 37 | export function parseProjectPath(path) { 38 | if (path.trim() === '') { 39 | return null; 40 | } 41 | 42 | { 43 | // Example: https://github.com/orgs/pingcap/projects/8 44 | // Example: https://github.com/tikv/tikv/projects/26 45 | const r = parseDirectProjectLink(path); 46 | if (r) { 47 | return r; 48 | } 49 | } 50 | 51 | { 52 | // Example: pingcap 53 | const m = path.match(/^([\w\d\.]+)$/); 54 | if (m) { 55 | return { 56 | type: 'org', 57 | org: m[1], 58 | }; 59 | } 60 | } 61 | 62 | const r = gh(path); 63 | if (r.owner && !r.name) { 64 | return { 65 | type: 'org', 66 | org: r.owner, 67 | }; 68 | } 69 | 70 | if (r.owner && r.name) { 71 | if (r.owner === 'orgs') { 72 | // Example: https://github.com/orgs/pingcap/projects 73 | return { 74 | type: 'org', 75 | org: r.name, 76 | }; 77 | } else { 78 | return { 79 | type: 'repo', 80 | org: r.owner, 81 | repo: r.name, 82 | }; 83 | } 84 | } 85 | 86 | return null; 87 | } 88 | 89 | export function loadProjectsByPath(path, octoClient) { 90 | const parsed = parseProjectPath(path); 91 | return loadProjectsByParsedInfo(parsed, octoClient); 92 | } 93 | 94 | export async function loadProjectsByParsedInfo(parsed, octoClient) { 95 | switch (parsed.type) { 96 | case 'org': 97 | return await octoClient.loadEnabledProjectsFromOrg(parsed.org); 98 | case 'repo': 99 | return await octoClient.loadEnabledProjectsFromRepo( 100 | parsed.org, 101 | parsed.repo 102 | ); 103 | case 'org_project': { 104 | const project = await octoClient.loadOrgProjectByProjNum( 105 | parsed.org, 106 | parsed.project_num 107 | ); 108 | return [project]; 109 | } 110 | case 'repo_project': { 111 | const project = await octoClient.loadRepoProjectByProjNum( 112 | parsed.org, 113 | parsed.repo, 114 | parsed.project_num 115 | ); 116 | return [project]; 117 | } 118 | default: 119 | return []; 120 | } 121 | } 122 | 123 | // Convert a project tree in the list hierarchy to a tree hierarchy. 124 | export function projectTreeListToTree(treeInList) { 125 | const idMap = {}; 126 | let roots = []; 127 | treeInList.forEach(item => { 128 | if (item.id) { 129 | idMap[item.id] = item; 130 | } 131 | }); 132 | treeInList.forEach(item => { 133 | const parentId = item.parentProject?.id; 134 | if (parentId) { 135 | const parent = idMap[parentId]; 136 | if (parent != null) { 137 | if (parent._children == null) { 138 | parent._children = []; 139 | } 140 | parent._children.push(item); 141 | } else { 142 | console.warn('Cannot find parent project id', parentId); 143 | } 144 | } else { 145 | roots.push(item); 146 | } 147 | }); 148 | return roots; 149 | } 150 | -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | const qs = require('querystring'); 2 | const randomString = require('randomstring'); 3 | const bodyParser = require('body-parser'); 4 | const axios = require('axios'); 5 | const cookieSession = require('cookie-session'); 6 | const { graphql } = require('@octokit/graphql'); 7 | const logger = require('signale'); 8 | 9 | function NewOctoClient(token) { 10 | let destToken; 11 | if (process.env.ACCESS_TOKEN_OVERRIDE) { 12 | destToken = process.env.ACCESS_TOKEN_OVERRIDE; 13 | } else { 14 | destToken = token; 15 | } 16 | const client = graphql.defaults({ 17 | headers: { 18 | authorization: `token ${destToken}`, 19 | }, 20 | }); 21 | return client; 22 | } 23 | 24 | async function acquireOAuthToken(code, state) { 25 | const oauthResp = await axios.request({ 26 | method: 'post', 27 | url: 28 | 'https://github.com/login/oauth/access_token?' + 29 | qs.stringify({ 30 | client_id: process.env.CLIENT_ID, 31 | client_secret: process.env.CLIENT_SECRET, 32 | code, 33 | state, 34 | }), 35 | headers: { 36 | accept: 'application/json', 37 | }, 38 | }); 39 | if (!oauthResp.data.access_token) { 40 | throw new Error('Invalid AccessToken response'); 41 | } 42 | const client = NewOctoClient(oauthResp.data.access_token); 43 | const resp = await client(`{ 44 | viewer { 45 | login 46 | avatarUrl 47 | id 48 | name 49 | } 50 | }`); 51 | if (!resp || !resp.viewer) { 52 | throw new Error('Invalid GitHub Authenticated response'); 53 | } 54 | return { 55 | accessToken: oauthResp.data.access_token, 56 | githubUser: resp.viewer, 57 | }; 58 | } 59 | 60 | function requireToken(req, res, next) { 61 | if (!process.env.ACCESS_TOKEN_OVERRIDE && !req.session.accessToken) { 62 | res.status(403).json({ 63 | err: 'SignInRequired', 64 | }); 65 | return; 66 | } 67 | next(); 68 | } 69 | 70 | module.exports = app => { 71 | app.use(bodyParser.urlencoded({ extended: false })); 72 | 73 | app.use(bodyParser.json()); 74 | 75 | app.use( 76 | cookieSession({ 77 | name: 'session', 78 | keys: [process.env.SESSION_SECRET], 79 | maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days 80 | }) 81 | ); 82 | 83 | app.get('/github/signin', (req, res) => { 84 | req.session.oAuthState = randomString.generate(); 85 | const githubAuthUrl = 86 | 'https://github.com/login/oauth/authorize?' + 87 | qs.stringify({ 88 | client_id: process.env.CLIENT_ID, 89 | redirect_uri: 90 | process.env.SERVER_HOST + 91 | '/github/callback?' + 92 | qs.stringify({ redirect: req.query.redirect }), 93 | state: req.session.oAuthState, 94 | scope: 'read:user user:email repo', 95 | }); 96 | res.redirect(githubAuthUrl); 97 | }); 98 | 99 | app.get('/github/callback', (req, res) => { 100 | if (req.session.oAuthState !== req.query.state) { 101 | logger.error('Check OAuthState failed', req.query, req.session); 102 | res.status(403).json({ 103 | err: 'InvaildOAuthState', 104 | }); 105 | return; 106 | } 107 | 108 | acquireOAuthToken(req.query.code, req.query.state).then( 109 | data => { 110 | req.session.accessToken = data.accessToken; 111 | req.session.githubUser = data.githubUser; 112 | logger.success('OAuth SignIn success', { 113 | token: data.accessToken, 114 | login: data.githubUser.login, 115 | }); 116 | if (req.query.redirect) { 117 | res.redirect(req.query.redirect); 118 | } else { 119 | res.redirect('/'); 120 | } 121 | }, 122 | err => { 123 | logger.error(err); 124 | res.status(500).json({ 125 | err: 'InternalError', 126 | msg: err.message, 127 | }); 128 | } 129 | ); 130 | }); 131 | 132 | app.get('/github/info', requireToken, (req, res) => { 133 | // Always verify the access token. 134 | const client = NewOctoClient(req.session.accessToken); 135 | client(`{ 136 | viewer { 137 | login 138 | } 139 | }`).then( 140 | _resp => { 141 | res.json({ 142 | accessToken: req.session.accessToken, 143 | githubUser: req.session.githubUser, 144 | }); 145 | }, 146 | _err => { 147 | req.session = null; 148 | res.status(403).json({ 149 | err: 'SignInRequired', 150 | }); 151 | return; 152 | } 153 | ); 154 | }); 155 | 156 | app.post('/github/graphql', requireToken, (req, res) => { 157 | const client = NewOctoClient(req.session.accessToken); 158 | client(req.body.query, req.body.parameters).then( 159 | resp => res.json(resp), 160 | err => { 161 | logger.error(err); 162 | if (err.message && err.message.indexOf('Bad credentials') > -1) { 163 | req.session = null; 164 | res.status(403).json({ 165 | err: 'SignInRequired', 166 | }); 167 | return; 168 | } 169 | res.status(500).json({ 170 | err: 'InternalError', 171 | msg: err.message, 172 | }); 173 | } 174 | ); 175 | }); 176 | 177 | app.post('/signout', requireToken, (req, res) => { 178 | req.session = null; 179 | res.json({}); 180 | }); 181 | }; 182 | -------------------------------------------------------------------------------- /src/components/GanttChangePreviewDialog.vue: -------------------------------------------------------------------------------- 1 | 126 | 127 | 153 | 154 | 167 | -------------------------------------------------------------------------------- /src/components/Nav.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 184 | -------------------------------------------------------------------------------- /src/pages/ManageLocalPanelsPage.vue: -------------------------------------------------------------------------------- 1 | 195 | 196 | 365 | -------------------------------------------------------------------------------- /src/plugins/octoclient.js: -------------------------------------------------------------------------------- 1 | import { Http } from 'vue-resource'; 2 | import some from 'lodash/some'; 3 | import { ToastProgrammatic as Toast } from 'buefy'; 4 | import * as utils from '@/utils.js'; 5 | 6 | const FLAG_PROJECT_ENABLE = 'EnableGantt'.toLowerCase(); 7 | 8 | const QUERY_FRAG_PROJECT = ` 9 | body 10 | id 11 | name 12 | number 13 | state 14 | url 15 | `; 16 | // owner { 17 | // __typename 18 | // ... on Organization { 19 | // login 20 | // } 21 | // ... on Repository { 22 | // name 23 | // owner { 24 | // ... on Organization { 25 | // login 26 | // } 27 | // ... on User { 28 | // login 29 | // } 30 | // } 31 | // } 32 | // } 33 | 34 | const QUERY_FRAG_ISSUE_OR_PR = ` 35 | id 36 | assignees(first: 1) { 37 | nodes { 38 | login 39 | } 40 | } 41 | author { 42 | login 43 | } 44 | body 45 | createdAt 46 | closed 47 | closedAt 48 | number 49 | url 50 | viewerCanUpdate 51 | title 52 | state 53 | repository { 54 | nameWithOwner 55 | } 56 | milestone { 57 | dueOn 58 | id 59 | title 60 | url 61 | state 62 | number 63 | } 64 | labels(first: 10) { 65 | nodes { 66 | name 67 | color 68 | } 69 | } 70 | `; 71 | 72 | const FLAG_REGEX_ITEM_IGNORE = /GanttIgnoreColumn:\s*([^\n\-]+)/i; 73 | 74 | class OctoClient { 75 | QUERY_FRAG_RATELIMIT = ` 76 | rateLimit { 77 | limit 78 | cost 79 | remaining 80 | resetAt 81 | } 82 | `; 83 | 84 | request = async (query, parameters) => { 85 | const resp = await Http.post('/github/graphql', { 86 | query, 87 | parameters, 88 | }); 89 | return resp.body; 90 | }; 91 | 92 | loadEnabledProjectsFromRepo = async (org, repo) => { 93 | const r = []; 94 | let after = null; 95 | for (;;) { 96 | const resp = await this.request( 97 | ` 98 | query loadEnabledProjectsFromRepo($org: String!, $repo: String!, $after: String) { 99 | repository(name: $repo, owner: $org) { 100 | projects(first: 100, after: $after) { 101 | pageInfo { 102 | hasNextPage 103 | endCursor 104 | } 105 | nodes { 106 | ${QUERY_FRAG_PROJECT} 107 | } 108 | } 109 | } 110 | ${this.QUERY_FRAG_RATELIMIT} 111 | } 112 | `, 113 | { 114 | org, 115 | repo, 116 | after, 117 | } 118 | ); 119 | if (!resp || !resp.repository) { 120 | console.log(resp); 121 | throw new Error('Invalid loadEnabledProjectsFromRepo response'); 122 | } 123 | console.log('loadEnabledProjectsFromRepo rateLimit', resp.rateLimit); 124 | console.log(resp.repository.projects); 125 | resp.repository.projects.nodes.forEach(n => { 126 | if (n.body.toLowerCase().indexOf(FLAG_PROJECT_ENABLE) > -1) { 127 | r.push(n); 128 | } 129 | }); 130 | if (!resp.repository.projects.pageInfo.hasNextPage) { 131 | break; 132 | } 133 | after = resp.repository.projects.pageInfo.endCursor; 134 | } 135 | return r; 136 | }; 137 | 138 | loadEnabledProjectsFromOrg = async org => { 139 | const r = []; 140 | let after = null; 141 | for (;;) { 142 | const resp = await this.request( 143 | ` 144 | query loadEnabledProjectsFromOrg($org: String!, $after: String) { 145 | organization(login: $org) { 146 | projects(first: 100, after: $after) { 147 | pageInfo { 148 | hasNextPage 149 | endCursor 150 | } 151 | nodes { 152 | ${QUERY_FRAG_PROJECT} 153 | } 154 | } 155 | } 156 | ${this.QUERY_FRAG_RATELIMIT} 157 | } 158 | `, 159 | { 160 | org, 161 | after, 162 | } 163 | ); 164 | if (!resp || !resp.organization) { 165 | console.log(resp); 166 | throw new Error('Invalid loadEnabledProjectsFromOrg response'); 167 | } 168 | console.log('loadEnabledProjectsFromOrg rateLimit', resp.rateLimit); 169 | console.log(resp.organization.projects.nodes); 170 | resp.organization.projects.nodes.forEach(n => { 171 | if (n.body.toLowerCase().indexOf(FLAG_PROJECT_ENABLE) > -1) { 172 | r.push(n); 173 | } 174 | }); 175 | if (!resp.organization.projects.pageInfo.hasNextPage) { 176 | break; 177 | } 178 | after = resp.organization.projects.pageInfo.endCursor; 179 | } 180 | return r; 181 | }; 182 | 183 | loadRepoProjectByProjNum = async (org, repo, num) => { 184 | console.log('Load repo project info: ', org, repo, num); 185 | 186 | const resp = await this.request( 187 | ` 188 | query loadRepoProjectByProjNum($org: String!, $repo: String!, $num: Int!) { 189 | repository(name: $repo, owner: $org) { 190 | project(number: $num) { 191 | ${QUERY_FRAG_PROJECT} 192 | } 193 | } 194 | ${this.QUERY_FRAG_RATELIMIT} 195 | } 196 | `, 197 | { 198 | org, 199 | repo, 200 | num, 201 | } 202 | ); 203 | if (!resp || !resp.repository) { 204 | console.log(resp); 205 | throw new Error('Invalid loadRepoProjectByProjNum response'); 206 | } 207 | console.log('loadRepoProjectByProjNum rateLimit', resp.rateLimit); 208 | return resp.repository.project; 209 | }; 210 | 211 | loadOrgProjectByProjNum = async (org, num) => { 212 | console.log('Load org project info: ', org, num); 213 | 214 | const resp = await this.request( 215 | ` 216 | query loadOrgProjectByProjNum($org: String!, $num: Int!) { 217 | organization(login: $org) { 218 | project(number: $num) { 219 | ${QUERY_FRAG_PROJECT} 220 | } 221 | } 222 | ${this.QUERY_FRAG_RATELIMIT} 223 | } 224 | `, 225 | { 226 | org, 227 | num, 228 | } 229 | ); 230 | if (!resp || !resp.organization) { 231 | console.log(resp); 232 | throw new Error('Invalid loadOrgProjectByProjNum response'); 233 | } 234 | console.log('loadOrgProjectByProjNum rateLimit', resp.rateLimit); 235 | return resp.organization.project; 236 | }; 237 | 238 | loadProjectItems = async projectIdArray => { 239 | if (projectIdArray.length === 0) { 240 | return []; 241 | } 242 | 243 | console.log('Load project items', projectIdArray); 244 | 245 | // Currently only first 100 card in each column is supported.. 246 | const r = []; 247 | const resp = await this.request( 248 | ` 249 | query loadProjectItems($ids: [ID!]!){ 250 | projects: nodes(ids: $ids) { 251 | ...on Project { 252 | id 253 | columns(first: 10) { 254 | nodes { 255 | id 256 | name 257 | cards(first: 100) { 258 | nodes { 259 | isArchived 260 | note 261 | content { 262 | __typename 263 | ... on PullRequest { 264 | ${QUERY_FRAG_ISSUE_OR_PR} 265 | } 266 | ... on Issue { 267 | ${QUERY_FRAG_ISSUE_OR_PR} 268 | } 269 | } 270 | } 271 | } 272 | } 273 | } 274 | } 275 | } 276 | ${this.QUERY_FRAG_RATELIMIT} 277 | } 278 | `, 279 | { 280 | ids: projectIdArray, 281 | } 282 | ); 283 | if (!resp || !resp.projects) { 284 | throw new Error('Invalid loadProjectItems response'); 285 | } 286 | console.log('loadProjectItems rateLimit', resp.rateLimit); 287 | resp.projects.forEach(proj => { 288 | proj.columns.nodes.forEach(column => { 289 | column.cards.nodes.forEach(card => { 290 | if (card.isArchived) { 291 | return; 292 | } 293 | if (!card.note && !card.content?.id) { 294 | // The card has either note or content 295 | return; 296 | } 297 | r.push({ 298 | note: card.note, 299 | content: card.content, 300 | parentColumn: { 301 | name: column.name, 302 | }, 303 | parentProject: { 304 | id: proj.id, 305 | }, 306 | }); 307 | }); 308 | }); 309 | }); 310 | return r; 311 | }; 312 | 313 | recursiveLoadProjectTree = async ( 314 | rootProjects, 315 | fnIncTotal, 316 | fnIncFinished 317 | ) => { 318 | console.group('recursiveLoadProjectTree'); 319 | 320 | async function wrapProjectWithParent(parentProjectId, promise) { 321 | fnIncTotal?.(); 322 | let r; 323 | try { 324 | r = await promise; 325 | r.parentProject = { 326 | id: parentProjectId, 327 | }; 328 | } catch (e) { 329 | let msg; 330 | if (e?.body?.msg) { 331 | msg = e.body.msg; 332 | } else { 333 | msg = e.message; 334 | } 335 | Toast.open({ 336 | duration: 1000, 337 | message: `Load project failed: ${msg}`, 338 | position: 'is-bottom', 339 | type: 'is-warning', 340 | queue: false, 341 | }); 342 | r = null; 343 | } 344 | fnIncFinished?.(); 345 | return r; 346 | } 347 | 348 | let projects = rootProjects; 349 | let depth = 0; 350 | const r = []; 351 | const idDedup = {}; 352 | 353 | while (projects.length > 0 && depth < 4) { 354 | const projectIgnoreColumnsByProjectId = {}; 355 | projects.forEach(proj => { 356 | // Match ignore column directives 357 | const m = proj.body.match(FLAG_REGEX_ITEM_IGNORE); 358 | if (m) { 359 | projectIgnoreColumnsByProjectId[proj.id] = m[1] 360 | .trim() 361 | .split(',') 362 | .map(v => v.trim().toLowerCase()); 363 | } 364 | }); 365 | 366 | const projectIdArrayToLoadItems = []; 367 | projects.forEach(p => { 368 | if (idDedup[p.id]) { 369 | return; 370 | } 371 | idDedup[p.id] = true; 372 | r.push({ 373 | kind: 'project', 374 | ...p, 375 | }); 376 | projectIdArrayToLoadItems.push(p.id); 377 | }); 378 | 379 | fnIncTotal?.(); 380 | const items = await this.loadProjectItems(projectIdArrayToLoadItems); 381 | fnIncFinished?.(); 382 | 383 | const projectInfoArrayToLoadMeta = []; 384 | items.forEach(i => { 385 | if (i.parentProject?.id && i.parentColumn?.name) { 386 | const ignoreColumns = 387 | projectIgnoreColumnsByProjectId[i.parentProject.id]; 388 | const name = i.parentColumn.name.toLowerCase(); 389 | if (ignoreColumns) { 390 | if (some(ignoreColumns, n => name.indexOf(n) > -1)) { 391 | return; 392 | } 393 | } 394 | } 395 | 396 | if (i.note?.length > 0) { 397 | const info = utils.parseDirectProjectLink(i.note); 398 | if (!info) { 399 | return; 400 | } 401 | info.parentProjectId = i.parentProject.id; 402 | const infoId = JSON.stringify(info); 403 | if (idDedup[infoId]) { 404 | return; 405 | } 406 | idDedup[infoId] = true; 407 | projectInfoArrayToLoadMeta.push(info); 408 | } else if (i.content?.id) { 409 | const issueOrPrNode = i.content; 410 | if (idDedup[issueOrPrNode.id]) { 411 | return; 412 | } 413 | idDedup[issueOrPrNode.id] = true; 414 | r.push({ 415 | kind: 'issueOrPr', 416 | parentColumn: i.parentColumn, 417 | parentProject: i.parentProject, 418 | ...issueOrPrNode, 419 | }); 420 | } 421 | }); 422 | 423 | const promises = []; 424 | projectInfoArrayToLoadMeta.forEach(info => { 425 | if (info.type === 'org_project') { 426 | promises.push( 427 | wrapProjectWithParent( 428 | info.parentProjectId, 429 | this.loadOrgProjectByProjNum(info.org, info.project_num) 430 | ) 431 | ); 432 | } else if (info.type === 'repo_project') { 433 | promises.push( 434 | wrapProjectWithParent( 435 | info.parentProjectId, 436 | this.loadRepoProjectByProjNum( 437 | info.org, 438 | info.repo, 439 | info.project_num 440 | ) 441 | ) 442 | ); 443 | } else { 444 | throw new Error('Unknown project info: ' + info.type); 445 | } 446 | }); 447 | 448 | if (promises.length === 0) { 449 | break; 450 | } 451 | 452 | const leafProjects = await Promise.all(promises); 453 | projects = leafProjects.filter(v => v != null); 454 | depth += 1; 455 | } 456 | 457 | console.groupEnd('recursiveLoadProjectTree'); 458 | 459 | return r; 460 | }; 461 | } 462 | 463 | export const octoClient = new OctoClient(); 464 | 465 | export const OctoClientPlugin = { 466 | install(Vue, _options) { 467 | Vue.prototype.$octoClient = octoClient; 468 | }, 469 | }; 470 | -------------------------------------------------------------------------------- /src/components/GanttView.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 1121 | 1122 | 1125 | 1126 | 1224 | --------------------------------------------------------------------------------