├── 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 |
2 |
3 |
4 |
5 |
15 |
--------------------------------------------------------------------------------
/src/pages/ViewLocalPanelPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 |
13 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.
14 |
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 |
2 |
3 |
4 | Sign In
5 | To continue, you need to sign-in use your GitHub account.
6 |
7 |
14 | Sign in use GitHub Account
15 |
16 |
17 |
18 |
19 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | GanttViewer
9 |
10 |
11 |
12 |
13 |
14 |
15 |
Getting Started
16 |
↑ Input the repository in the navigation bar.
17 |
18 |
19 |
20 |
21 |
Changelog
22 |
23 |
24 | 2020-02-27 Support export and import local panel JSON
25 |
26 | 2020-02-26 Support local panels
27 | 2020-02-24 Support private repository
28 |
29 |
30 |
31 |
32 |
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 |
2 |
3 |
4 |
11 |
12 |
13 |
16 |
17 |
18 |
21 |
22 |
23 |
24 |
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 |
2 |
3 |
6 |
7 |
8 | You have made following changes. Would you like to update GitHub issues
9 | to apply these changes?
10 | Unselected changes will be discarded.
11 |
12 |
21 |
22 |
23 |
29 |
35 |
41 |
47 |
54 |
55 |
56 |
57 | {{
58 | props.row.task.text
59 | }}
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | {{
68 | props.row.start_date.from | date
69 | }}
70 |
71 |
72 |
73 |
74 | {{ props.row.start_date.to | date }}
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | {{
84 | props.row.end_date.from | date
85 | }}
86 |
87 |
88 |
89 |
90 | {{ props.row.end_date.to | date }}
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | {{
100 | props.row.progress.from | percent
101 | }}
102 |
103 |
104 |
105 |
106 | {{ props.row.progress.to | percent }}
107 |
108 |
109 |
110 |
111 |
112 |
113 |
124 |
125 |
126 |
127 |
153 |
154 |
167 |
--------------------------------------------------------------------------------
/src/components/Nav.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | GanttViewer
7 |
8 |
9 |
10 | for:
11 |
27 | >
28 |
29 |
30 | {{ props.option.display }}
31 |
32 |
33 |
34 |
35 | {{ props.option.stargazers.totalCount }}
36 |
37 | {{ props.option.forkCount }}
38 |
39 |
40 |
41 |
42 |
43 |
44 | {{ panel.name }}
51 | Manage Local Panels
54 |
55 |
56 |
57 |
58 |
59 |
63 | {{ sessionInfo.githubUser.login }}
64 |
65 | Logout
68 |
69 |
70 |
71 |
72 |
73 |
184 |
--------------------------------------------------------------------------------
/src/pages/ManageLocalPanelsPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
Manage Local Panels
8 |
9 |
10 | Add Local Panel
18 |
19 |
20 | Import Panel JSON
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
42 |
48 |
49 |
50 |
62 |
63 | Add projects
70 |
83 | Add projects from
84 | organization
85 | {{ addProjectStates[panel.id].org }}
86 |
87 |
100 | Add projects from
101 | repository
102 | {{ addProjectStates[panel.id].org }}/{{
103 | addProjectStates[panel.id].repo
104 | }}
105 |
106 |
119 | Add
120 | project id
121 | {{ addProjectStates[panel.id].project_num }} from
122 | organization
123 | {{ addProjectStates[panel.id].org }}
124 |
125 |
138 | Add
139 | project id
140 | {{ addProjectStates[panel.id].project_num }} from
141 | repository
142 | {{ addProjectStates[panel.id].org }}/{{
143 | addProjectStates[panel.id].repo
144 | }}
145 |
146 |
147 |
148 |
149 |
150 |
151 | {{
152 | props.row.name
153 | }}
154 |
155 |
156 | {{ props.row.url }}
157 |
158 |
159 |
160 | Delete
167 |
169 |
170 |
171 |
172 |
173 |
174 |
190 |
191 |
192 |
193 |
194 |
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 |
2 |
3 |
4 |
5 |
6 |
Loading data from GitHub...
7 |
13 |
14 |
15 |
22 |
27 |
28 |
33 |
34 |
35 |
44 |
45 |
46 |
52 |
53 |
Zoom In
60 |
Zoom Out
67 |
Collapse All
73 |
Expand All
79 |
Reload
80 |
Preview & Save
87 |
88 |
89 |
90 |
91 |
1121 |
1122 |
1125 |
1126 |
1224 |
--------------------------------------------------------------------------------