├── .babelrc ├── .env.example ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── heroku-ping-test.js ├── icons ├── icon128.png ├── icon16.png └── icon48.png ├── manifest.json ├── package.json ├── server ├── apis │ └── v1 │ │ └── modules │ │ ├── Note │ │ ├── note.controller.js │ │ ├── note.model.js │ │ └── note.service.js │ │ ├── Oauth │ │ ├── oauth.controller.js │ │ └── oauth.service.js │ │ └── User │ │ ├── user.controller.js │ │ ├── user.model.js │ │ └── user.service.js ├── app.js ├── middlewares │ └── validateRequest.js ├── public │ └── oauth │ │ └── success.ejs ├── routes │ ├── index.js │ ├── note.routes.js │ ├── oauth.routes.js │ └── user.routes.js └── utils │ ├── cache.js │ ├── constants.js │ └── githubapi.js ├── src ├── ajax.js ├── api.js ├── background.js ├── constants.example.js ├── footer.js ├── helpers.js ├── index.html ├── index.js ├── noteBox.js └── style.css ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DB_URI = 'mongodb://localhost/gitx' 2 | GITHUB_CLIENT_ID = 3 | GITHUB_CLIENT_SECRET = 4 | GITHUB_API_URL = https://api.github.com/ 5 | JWT_KEY = 6 | REDIS_URL = 'redis://127.0.0.1:6379' 7 | HEROKU_URL= 8 | WELCOME_URL=https://gitxapp.com/welcome.html -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true 6 | }, 7 | extends: ["airbnb-base"], 8 | globals: { 9 | Atomics: "readonly", 10 | SharedArrayBuffer: "readonly" 11 | }, 12 | parserOptions: { 13 | ecmaVersion: 2018, 14 | sourceType: "module" 15 | }, 16 | rules: { 17 | "arrow-parens": "off", 18 | "no-underscore-dangle": "off", 19 | quotes: "off", 20 | "comma-dangle": "off", 21 | "nonblock-statement-body-position": "off", 22 | "object-curly-newline": "off" 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | /dist.zip 4 | .DS_Store 5 | .vscode 6 | .env 7 | src/constants.js 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-2021 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | GitX 4 | 5 |

6 | 7 |

Chrome extension for adding private comments in Github 8 |

9 | 10 |
11 | Built with ❤︎ 12 |
13 | 14 |

15 | Commits-per-month 16 | PR-Welcome 17 | License 18 | 19 |

20 | 21 |

22 | Buy Me A Coffee 23 |

24 | 25 | ## Getting started 26 | 27 | #### Clone the project 28 | 29 | ```sh 30 | # clone it 31 | git clone https://github.com/gitxapp/gitx.git 32 | cd gitx 33 | ``` 34 | 35 | ## Preliminary Steps 36 | 37 | #### Create a new app in Github 38 | 39 | - Create a new oauth app in github by going to https://github.com/settings/developers 40 | - Set call back url as http://localhost:5000/api/v1/oauth/redirect 41 | 42 | #### Setup environment variables for back end 43 | 44 | Add `.env` file under the top level of the project. 45 | 46 | Add the following details 47 | 48 | ``` 49 | DB_URI = 'mongodb://localhost/gitx' 50 | GITHUB_CLIENT_ID = 51 | GITHUB_CLIENT_SECRET = 52 | JWT_KEY = 53 | REDIS_URL = 'redis://127.0.0.1:6379' 54 | HEROKU_URL= 55 | WELCOME_URL='https://gitxapp.com/welcome.html' 56 | 57 | ``` 58 | 59 | #### Setup environment variables for chrome extension 60 | 61 | Add `constants.js` file under the `src` folder 62 | 63 | Add the following details 64 | 65 | ``` 66 | export const VERSION = "v1"; 67 | export const INSTALL_URL = "https://gitxapp.com/connect.html"; 68 | export const UN_INSTALL_URL = "https://gitxapp.com/uninstall.html"; 69 | export const URL = "http://localhost:5000/api/"; 70 | export const REDIRECT_URL = "GITHUB_CALLBACK_URL"; 71 | export const APP_ID = "GITHUB_APP_CLIENT_ID"; 72 | 73 | ``` 74 | 75 | ## Running the project 76 | 77 | #### Run the back end 78 | 79 | ``` 80 | # Install dependencies 81 | yarn install 82 | # Start the app 83 | yarn server 84 | 85 | ``` 86 | 87 | #### Run chrome extension 88 | 89 | ``` 90 | # Make a build 91 | yarn client 92 | ``` 93 | 94 | ``` 95 | # Open chrome://extensions/ from your chrome browser and enable Developer mode 96 | ``` 97 | 98 | ``` 99 | # Click on "Load unpacked" button and upload the build folder 100 | ``` 101 | 102 | ## Feedback & Contributing 103 | 104 | Feel free to send us feedback on [Twitter](https://twitter.com/GitXApp) or [file an issue](https://github.com/gitxapp/gitx/issues). 105 | 106 | _Gitxapp_ © 2019-2020 - Released under the MIT License. 107 | -------------------------------------------------------------------------------- /heroku-ping-test.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | 3 | console.log('Heroku ping test started'); 4 | 5 | function startKeepAlive() { 6 | setInterval(() => { 7 | const options = { 8 | host: process.env.HEROKU_URL, 9 | port: 80, 10 | path: '/', 11 | }; 12 | http 13 | .get(options, res => { 14 | res.on('data', chunk => { 15 | try { 16 | // optional logging... disable after it's working 17 | console.log(`HEROKU RESPONSE: ${chunk}`); 18 | } catch (err) { 19 | console.log(err.message); 20 | } 21 | }); 22 | }) 23 | .on('error', err => { 24 | console.log(`Error: ${err.message}`); 25 | }); 26 | }, 20 * 60 * 1000); // load every 20 minutes 27 | } 28 | startKeepAlive(); 29 | -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitxapp/gitx/0636df17a3fc5b73d1e41906ec62d6c1a021aa90/icons/icon128.png -------------------------------------------------------------------------------- /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitxapp/gitx/0636df17a3fc5b73d1e41906ec62d6c1a021aa90/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitxapp/gitx/0636df17a3fc5b73d1e41906ec62d6c1a021aa90/icons/icon48.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GitX- Private notes for open source projects", 3 | "short_name": "GitX", 4 | "version": "1.0.1", 5 | "manifest_version": 2, 6 | "description": "Chrome extension for adding private notes in Github", 7 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", 8 | "browser_action": { 9 | "default_popup": "index.html", 10 | "default_title": "GitX", 11 | "default_icon": "icons/icon128.png" 12 | }, 13 | "icons": { 14 | "16": "icons/icon16.png", 15 | "48": "icons/icon48.png", 16 | "128": "icons/icon128.png" 17 | }, 18 | "permissions": ["storage"], 19 | "background": { 20 | "scripts": ["background.js"], 21 | "persistent": false, 22 | "css": ["widget.css"] 23 | }, 24 | "content_scripts": [ 25 | { 26 | "matches": [ 27 | "https://github.com/*", 28 | "https://gitextended.herokuapp.com/*", 29 | "https://gitx-app.herokuapp.com/*", 30 | "https://gitxapp.com/*" 31 | ], 32 | "js": ["main.js"] 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GitX", 3 | "version": "1.0.1", 4 | "license": "MIT", 5 | "description": "Chrome extension for adding private comments in Github", 6 | "main": "webpack.config.js", 7 | "scripts": { 8 | "start:f": "webpack-dev-server --mode development --open", 9 | "dev": "webpack --mode development", 10 | "client": "webpack --mode production", 11 | "start": "nodemon -r esm server/app.js & nodemon heroku-ping-test.js", 12 | "server": "DEBUG=express:* babel-node server/app.js", 13 | "debug": "nodemon -r esm --inspect server/app.js" 14 | }, 15 | "devDependencies": { 16 | "@babel/cli": "^7.5.5", 17 | "@babel/core": "^7.5.5", 18 | "@babel/node": "^7.5.5", 19 | "@babel/preset-env": "^7.5.5", 20 | "babel-cli": "^6.26.0", 21 | "babel-core": "^6.26.3", 22 | "babel-loader": "^7.1.5", 23 | "babel-polyfill": "^6.26.0", 24 | "babel-preset-es2015": "^6.24.1", 25 | "babel-preset-stage-0": "^6.24.1", 26 | "copy-webpack-plugin": "^5.0.3", 27 | "css-loader": "^3.0.0", 28 | "eslint": "^5.16.0", 29 | "eslint-config-airbnb-base": "^13.2.0", 30 | "eslint-plugin-import": "^2.18.2", 31 | "html-loader": "^0.5.5", 32 | "html-webpack-plugin": "^3.2.0", 33 | "style-loader": "^0.23.1", 34 | "webpack": "^4.36.1", 35 | "webpack-cli": "^3.3.6", 36 | "webpack-dev-server": "^3.7.2" 37 | }, 38 | "dependencies": { 39 | "axios": "^0.19.0", 40 | "body-parser": "^1.19.0", 41 | "concurrently": "^4.1.1", 42 | "esm": "^3.2.25", 43 | "cors": "^2.8.5", 44 | "dotenv": "^8.0.0", 45 | "ejs": "^2.6.2", 46 | "express": "^4.17.1", 47 | "js-cookie": "^2.2.0", 48 | "jsonwebtoken": "^8.5.1", 49 | "mongoose": "^5.7.5", 50 | "nodemon": "^1.19.1", 51 | "path-parser": "^4.2.0", 52 | "redis": "^2.8.0", 53 | "showdown": "^1.9.0", 54 | "util": "^0.12.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /server/apis/v1/modules/Note/note.controller.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-cycle 2 | import NoteService from "./note.service"; 3 | 4 | async function createNoteController(req, res) { 5 | const noteDetails = req.body; 6 | const action = await NoteService.createNote(req.user, noteDetails); 7 | res.status(action.status).send({ 8 | message: action.message, 9 | data: action.data || {} 10 | }); 11 | } 12 | 13 | async function getNotesController(req, res) { 14 | const noteDetails = req.body; 15 | 16 | const action = await NoteService.getNotes(req.user, noteDetails); 17 | res.status(action.status).send({ 18 | message: action.message, 19 | data: action.data || {} 20 | }); 21 | } 22 | 23 | async function deleteNotesController(req, res) { 24 | const noteDetails = req.body; 25 | 26 | const action = await NoteService.deleteNote(req.user._id, noteDetails); 27 | res.status(action.status).send({ 28 | message: action.message, 29 | data: action.data || {} 30 | }); 31 | } 32 | 33 | async function editNotesController(req, res) { 34 | const noteDetails = req.body; 35 | 36 | const action = await NoteService.editNote(req.user._id, noteDetails); 37 | res.status(action.status).send({ 38 | message: action.message, 39 | data: action.data || {} 40 | }); 41 | } 42 | 43 | export default { 44 | createNoteController, 45 | getNotesController, 46 | deleteNotesController, 47 | editNotesController 48 | }; 49 | -------------------------------------------------------------------------------- /server/apis/v1/modules/Note/note.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const { Schema } = mongoose; 4 | 5 | const noteSchema = new Schema( 6 | { 7 | userId: { type: Schema.Types.ObjectId, ref: "User", required: true }, 8 | projectName: { 9 | type: String, 10 | required: true 11 | }, 12 | noteType: String, // pull or issue 13 | issueId: { 14 | type: String, 15 | required: true 16 | }, // issue id or pull id 17 | repoOwner: { 18 | type: String, 19 | required: true 20 | }, // repo owner name 21 | repoOwnerType: { 22 | type: String, 23 | required: true 24 | }, // user or organization 25 | noteVisibility: { 26 | type: Boolean, 27 | default: false 28 | }, 29 | noteContent: String, 30 | nearestCommentId: String, 31 | nearestCreatedDate: { type: Date }, 32 | isActive: { type: Boolean, default: true } 33 | }, 34 | { timestamps: true } 35 | ); 36 | 37 | noteSchema.post("save", (doc, next) => { 38 | doc 39 | .populate("userId", "userName avatarUrl githubId") 40 | .execPopulate() 41 | .then(() => { 42 | next(); 43 | }); 44 | }); 45 | 46 | export default mongoose.model("Note", noteSchema); 47 | -------------------------------------------------------------------------------- /server/apis/v1/modules/Note/note.service.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import showdown from "showdown"; 3 | 4 | import Note from "./note.model"; 5 | 6 | import { 7 | getRepoOwnerType, 8 | checkUserIsACollaborator, 9 | } from "../../../../utils/githubapi"; 10 | 11 | const converter = new showdown.Converter(); 12 | 13 | async function createNote(user, noteDetails) { 14 | const { 15 | noteContent, 16 | noteType, 17 | issueId, 18 | projectName, 19 | repoOwner, 20 | noteVisibility, 21 | nearestCommentId, 22 | } = noteDetails; 23 | 24 | let userHasAccessToRepo = false; 25 | const { _id: userId, userName, accessToken, avatarUrl, githubId } = user; 26 | 27 | const userDetails = { userName, avatarUrl, githubId }; 28 | 29 | userHasAccessToRepo = await checkUserIsACollaborator({ 30 | repoOwner, 31 | projectName, 32 | userName, 33 | accessToken, 34 | }); 35 | 36 | if (!userHasAccessToRepo) { 37 | return { 38 | status: 400, 39 | message: 40 | "You cannot add private notes to this repository since you are not a actual collaborator", 41 | }; 42 | } 43 | 44 | const repoOwnerType = await getRepoOwnerType({ repoOwner }); 45 | 46 | const note = new Note({ 47 | noteContent, 48 | repoOwnerType, 49 | noteType, 50 | issueId, 51 | nearestCommentId, 52 | projectName, 53 | repoOwner, 54 | noteVisibility, 55 | userId, 56 | userDetails, 57 | }); 58 | 59 | if (!userId) { 60 | return { 61 | status: 400, 62 | message: "User id is required", 63 | }; 64 | } 65 | if (!issueId) { 66 | return { 67 | status: 400, 68 | message: "Issue id or Pull id is required", 69 | }; 70 | } 71 | if (!projectName) { 72 | return { 73 | status: 400, 74 | message: "Project name is required", 75 | }; 76 | } 77 | 78 | try { 79 | const result = await note.save(); 80 | 81 | const newlyCreatedNote = { 82 | _id: result._id, 83 | noteContent: converter.makeHtml(result.noteContent), 84 | author: result.userId, 85 | createdAt: result.createdAt, 86 | updatedAt: result.updatedAt, 87 | nearestCommentId: result.nearestCommentId, 88 | noteVisibility: result.noteVisibility, 89 | userDetails, 90 | }; 91 | 92 | return { 93 | status: 200, 94 | data: newlyCreatedNote, 95 | message: "Note created successfully", 96 | }; 97 | } catch (err) { 98 | return { 99 | status: 401, 100 | data: { error: err }, 101 | message: "Note not created", 102 | }; 103 | } 104 | } 105 | 106 | async function getNotes(user, noteDetails) { 107 | try { 108 | let notes = []; 109 | let userHasAccessToRepo = false; 110 | const { _id: userId, userName, avatarUrl, githubId, accessToken } = user; 111 | const { projectName, issueId, noteType, repoOwner } = noteDetails; 112 | 113 | userHasAccessToRepo = await checkUserIsACollaborator({ 114 | repoOwner, 115 | projectName, 116 | userName, 117 | accessToken, 118 | }); 119 | 120 | if (userHasAccessToRepo) { 121 | notes = await Note.find({ 122 | $and: [ 123 | { issueId }, 124 | { projectName }, 125 | { noteType }, 126 | { $or: [{ userId }, { noteVisibility: true }] }, 127 | ], 128 | }).populate("userId", "userName avatarUrl githubId"); 129 | const userDetails = { userName, avatarUrl, githubId }; 130 | 131 | notes = notes.map((note) => ({ 132 | _id: note._id, 133 | noteContent: converter.makeHtml(note.noteContent), 134 | author: note.userId, 135 | createdAt: note.createdAt, 136 | updatedAt: note.updatedAt, 137 | nearestCommentId: note.nearestCommentId, 138 | noteVisibility: note.noteVisibility, 139 | userDetails, 140 | })); 141 | } 142 | 143 | return { 144 | status: 200, 145 | message: "Fetched notes", 146 | data: notes, 147 | }; 148 | } catch (err) { 149 | return { 150 | status: 200, 151 | message: "Failed to fetch notes", 152 | data: { error: err }, 153 | }; 154 | } 155 | } 156 | 157 | async function deleteNote(userId, noteDetails) { 158 | const { projectName, issueId, noteType, noteId } = noteDetails; 159 | if (!noteId) { 160 | return { 161 | status: 400, 162 | message: "Note id is required", 163 | }; 164 | } 165 | 166 | try { 167 | const note = await Note.findOne({ 168 | $and: [ 169 | { _id: mongoose.Types.ObjectId(noteId) }, 170 | { userId }, 171 | { issueId }, 172 | { projectName }, 173 | { noteType }, 174 | ], 175 | }).populate("userId", "userName avatarUrl githubId"); 176 | if (note) note.remove(); 177 | return { 178 | status: 200, 179 | data: note, 180 | message: "Note removed successfully", 181 | }; 182 | } catch (err) { 183 | return { 184 | status: 401, 185 | data: { error: err }, 186 | message: "Invalid note id", 187 | }; 188 | } 189 | } 190 | 191 | async function editNote(userId, noteDetails) { 192 | const { noteVisibility, noteId } = noteDetails; 193 | if (!noteId) { 194 | return { 195 | status: 400, 196 | message: "Note id is required", 197 | }; 198 | } 199 | 200 | try { 201 | await Note.findOneAndUpdate( 202 | { _id: mongoose.Types.ObjectId(noteId) }, 203 | { noteVisibility } 204 | ); 205 | 206 | return { 207 | status: 200, 208 | message: "Note updated successfully", 209 | }; 210 | } catch (err) { 211 | return { 212 | status: 401, 213 | data: { error: err }, 214 | message: "Invalid note id", 215 | }; 216 | } 217 | } 218 | export default { 219 | createNote, 220 | getNotes, 221 | deleteNote, 222 | editNote, 223 | }; 224 | -------------------------------------------------------------------------------- /server/apis/v1/modules/Oauth/oauth.controller.js: -------------------------------------------------------------------------------- 1 | import OauthService from './oauth.service'; 2 | 3 | async function createOauth(req, res) { 4 | const action = await OauthService.authRedirectService(req.query.code); 5 | if (action.status === 200) { 6 | res.redirect(`${process.env.WELCOME_URL}?accessToken=${action.data.accessToken}`); 7 | } else { 8 | res.status(action.status).send({ 9 | message: action.message, 10 | data: action.data || {}, 11 | }); 12 | } 13 | } 14 | export default { 15 | createOauth, 16 | }; 17 | -------------------------------------------------------------------------------- /server/apis/v1/modules/Oauth/oauth.service.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import UserService from '../User/user.service'; 3 | 4 | async function authRedirectService(requestToken) { 5 | try { 6 | const accessTokenResponse = await axios({ 7 | method: 'post', 8 | url: `https://github.com/login/oauth/access_token?client_id=${ 9 | process.env.GITHUB_CLIENT_ID 10 | }&client_secret=${process.env.GITHUB_CLIENT_SECRET}&code=${requestToken}`, 11 | headers: { 12 | accept: 'application/json', 13 | }, 14 | }); 15 | const { access_token: accessToken } = accessTokenResponse.data; 16 | if (!accessToken) { 17 | return { 18 | status: 400, 19 | message: 'Oauth failed', 20 | data: { 21 | error: accessTokenResponse.data, 22 | }, 23 | }; 24 | } 25 | const authParams = accessTokenResponse.data; 26 | const userDetailsResponse = await axios({ 27 | method: 'get', 28 | url: 'https://api.github.com/user', 29 | headers: { 30 | accept: 'application/json', 31 | Authorization: `token ${accessToken}`, 32 | }, 33 | }); 34 | const { 35 | login: userName, 36 | avatar_url: avatarUrl, 37 | email, 38 | bio, 39 | company, 40 | location, 41 | id: githubId, 42 | } = userDetailsResponse.data; 43 | const response = await UserService.createOrUpdate({ 44 | userName, 45 | githubId, 46 | email, 47 | avatarUrl, 48 | company, 49 | location, 50 | bio, 51 | authParams, 52 | accessToken, 53 | }); 54 | 55 | const { token, expiresIn } = response.data; 56 | 57 | return { 58 | status: 200, 59 | message: 'User logged in', 60 | data: { 61 | accessToken: token, 62 | expiresIn, 63 | userName, 64 | }, 65 | }; 66 | } catch (error) { 67 | return { 68 | status: 400, 69 | message: 'User not created', 70 | data: { 71 | error, 72 | }, 73 | }; 74 | } 75 | } 76 | 77 | export default { 78 | authRedirectService, 79 | }; 80 | -------------------------------------------------------------------------------- /server/apis/v1/modules/User/user.controller.js: -------------------------------------------------------------------------------- 1 | import userService from './user.service'; 2 | 3 | async function createUserController(req, res) { 4 | const action = await userService.createUser(req.body); 5 | res.status(action.status).send({ 6 | message: action.message, 7 | data: action.data || {}, 8 | }); 9 | } 10 | 11 | async function getUsersController(req, res) { 12 | const action = await userService.getUsers(); 13 | res.json(action.data || {}); 14 | } 15 | 16 | export default { 17 | createUserController, 18 | getUsersController, 19 | }; 20 | -------------------------------------------------------------------------------- /server/apis/v1/modules/User/user.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const { Schema } = mongoose; 4 | const userSchema = new Schema( 5 | { 6 | userName: { type: String, default: null }, 7 | name: { type: String, default: null }, 8 | githubId: { 9 | type: String, 10 | default: null, 11 | unique: true, 12 | required: true, 13 | }, 14 | email: { type: String }, 15 | avatarUrl: { type: String, default: null }, 16 | company: { type: String, default: null }, 17 | location: { type: String, default: null }, 18 | bio: { type: String, default: null }, 19 | authParams: { type: Schema.Types.Mixed }, 20 | accessToken: String, 21 | }, 22 | { timestamps: true }, 23 | ); 24 | export default mongoose.model('User', userSchema); 25 | -------------------------------------------------------------------------------- /server/apis/v1/modules/User/user.service.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | 3 | import User from "./user.model"; 4 | 5 | import Note from "../Note/note.model"; 6 | 7 | async function createUser(userDetails) { 8 | const user = new User(userDetails); 9 | try { 10 | await user.save(); 11 | 12 | return { 13 | data: user, 14 | status: 200, 15 | message: "User created" 16 | }; 17 | } catch (err) { 18 | if (err.name === "MongoError" && err.code === 11000) { 19 | return { 20 | status: 401, 21 | message: "Email Id already registered" 22 | }; 23 | } 24 | return { 25 | status: 500, 26 | message: "User not created", 27 | data: { error: err } 28 | }; 29 | } 30 | } 31 | 32 | async function createOrUpdate({ 33 | userName, 34 | githubId, 35 | email, 36 | avatarUrl, 37 | company, 38 | location, 39 | bio, 40 | authParams, 41 | accessToken 42 | }) { 43 | const userDetails = { 44 | userName, 45 | githubId, 46 | email, 47 | avatarUrl, 48 | company, 49 | location, 50 | bio, 51 | authParams, 52 | accessToken 53 | }; 54 | try { 55 | await User.update({ githubId }, userDetails, { upsert: true }); 56 | const updatedUser = await User.findOne({ githubId }); 57 | 58 | userDetails._id = updatedUser._id; 59 | userDetails.expiresIn = "7d"; 60 | userDetails.token = jwt.sign(userDetails, process.env.JWT_KEY); 61 | 62 | return { 63 | data: userDetails, 64 | status: 200, 65 | message: "Logged in" 66 | }; 67 | } catch (err) { 68 | return { 69 | status: 500, 70 | message: "User not created", 71 | data: { error: err } 72 | }; 73 | } 74 | } 75 | 76 | async function getUsers() { 77 | try { 78 | const users = await User.find({}).select( 79 | "userName name githubId email avatarUrl company location bio" 80 | ); 81 | const notes = await Note.find({}); 82 | return { 83 | status: 200, 84 | data: { 85 | totalUsers: users.length, 86 | totalNotes: notes.length, 87 | allUsers: users 88 | } 89 | }; 90 | } catch (err) { 91 | return { 92 | status: 200, 93 | message: "Failed to fetch users", 94 | data: { error: err } 95 | }; 96 | } 97 | } 98 | 99 | export default { 100 | createUser, 101 | createOrUpdate, 102 | getUsers 103 | }; 104 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import express from "express"; 3 | import cors from "cors"; 4 | import mongoose from "mongoose"; 5 | import dotenv from "dotenv"; 6 | import ejs from "ejs"; 7 | 8 | import { json, urlencoded } from "body-parser"; 9 | import routes from "./routes"; 10 | import validateRequest from "./middlewares/validateRequest"; 11 | 12 | dotenv.config(); 13 | 14 | function connect() { 15 | const options = { 16 | keepAlive: 10, 17 | useCreateIndex: true, 18 | useNewUrlParser: true 19 | }; 20 | mongoose.connect(process.env.DB_URI, options); 21 | } 22 | connect(); 23 | mongoose.connection.on("connected", () => console.log("Connected to MongoDB")); 24 | mongoose.connection.on("error", err => console.log(err)); 25 | mongoose.connection.on("disconnected", connect); 26 | 27 | // mongoose.set("debug", true); 28 | // Initialize Redis 29 | // require("./services/cache"); 30 | const app = express(); 31 | 32 | app.use(express.static(`${__dirname}/public`)); 33 | app.set("views", `${__dirname}/public`); 34 | app.engine("html", ejs.renderFile); 35 | 36 | app.set("view engine", "html"); 37 | app.use(json()); 38 | app.use(urlencoded({ extended: true })); 39 | 40 | app.use( 41 | cors({ 42 | origin: "*", 43 | credentials: true 44 | }) 45 | ); 46 | app.get("/", (req, res) => { 47 | res.send({ 48 | message: "App is working" 49 | }); 50 | }); 51 | // eslint-disable-next-line global-require 52 | app.all("/api/*", validateRequest); 53 | 54 | // API Routes 55 | app.use("/api/v1", routes); 56 | 57 | const PORT = process.env.PORT || 5000; 58 | app.listen(PORT, () => { 59 | console.log("Server started on port", PORT); 60 | }); 61 | -------------------------------------------------------------------------------- /server/middlewares/validateRequest.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import { checkTokenExpiredOrNot } from "../utils/githubapi"; 3 | 4 | // eslint-disable-next-line 5 | export default async (req, res, next) => { 6 | try { 7 | const { url, headers } = req; 8 | if (url.includes("oauth") || url.includes("user/all")) return next(); 9 | 10 | const { authorization } = headers; 11 | const token = authorization.split(" ")[1]; 12 | if (token) { 13 | const decoded = jwt.verify(token, process.env.JWT_KEY); 14 | if (decoded) { 15 | req.user = decoded; 16 | } 17 | if (url.includes("note")) { 18 | const tokenExpired = await checkTokenExpiredOrNot({ 19 | accessToken: decoded.accessToken, 20 | }); 21 | if (!tokenExpired) { 22 | res.status(401).send({ 23 | status: 401, 24 | message: 25 | "Sorry, your GitX token has expired, please do login again.", 26 | logout: true, 27 | }); 28 | } 29 | return next(); 30 | } 31 | res.status(422).send({ 32 | message: "Sorry, invalid token", 33 | }); 34 | } else { 35 | res.status(422).send({ 36 | message: "Sorry, token is missing", 37 | }); 38 | } 39 | } catch (err) { 40 | res.status(422).send({ 41 | message: "Sorry, invalid token", 42 | }); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /server/public/oauth/success.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

5 | Reload the opened github pages to view the GitX plugin features 6 |

7 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import UserRoutes from './user.routes'; 3 | import NoteRoutes from './note.routes'; 4 | import OauthRoutes from './oauth.routes'; 5 | 6 | const router = Router(); 7 | router.use('/user', UserRoutes); 8 | router.use('/oauth', OauthRoutes); 9 | router.use('/note', NoteRoutes); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /server/routes/note.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | 3 | import NoteController from "../apis/v1/modules/Note/note.controller"; 4 | 5 | const router = Router(); 6 | // Create a new note 7 | router.post("/", NoteController.createNoteController); 8 | // Get note details from userId 9 | router.post("/all", NoteController.getNotesController); 10 | // Delete a note 11 | router.post("/delete", NoteController.deleteNotesController); 12 | 13 | // Delete a note 14 | router.post("/edit", NoteController.editNotesController); 15 | 16 | export default router; 17 | -------------------------------------------------------------------------------- /server/routes/oauth.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import OauthController from '../apis/v1/modules/Oauth/oauth.controller'; 3 | 4 | const router = Router(); 5 | 6 | router.get('/redirect', OauthController.createOauth); 7 | export default router; 8 | -------------------------------------------------------------------------------- /server/routes/user.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import UserController from '../apis/v1/modules/User/user.controller'; 3 | 4 | const router = Router(); 5 | 6 | router.post('/', UserController.createUserController); 7 | 8 | router.get('/all', UserController.getUsersController); 9 | 10 | export default router; 11 | -------------------------------------------------------------------------------- /server/utils/cache.js: -------------------------------------------------------------------------------- 1 | import { Query } from 'mongoose'; 2 | import { createClient } from 'redis'; 3 | import { promisify } from 'util'; 4 | 5 | const client = createClient(process.env.REDIS_URL); 6 | client.hget = promisify(client.hget); 7 | const { exec } = Query.prototype; 8 | 9 | Query.prototype.cache = (options = {}) => { 10 | this.useCache = true; 11 | this.hashKey = JSON.stringify(options.key || ''); 12 | return this; 13 | }; 14 | 15 | Query.prototype.exec = async (...args) => { 16 | if (!this.useCache) { 17 | return exec.apply(this, args); 18 | } 19 | const key = JSON.stringify( 20 | Object.assign({}, this.getQuery(), { 21 | collection: this.mongooseCollection.name, 22 | }), 23 | ); 24 | // See if we have a value for 'key' in redis 25 | const cacheValue = await client.hget(this.hashKey, key); 26 | // If we do, return that 27 | if (cacheValue) { 28 | const doc = JSON.parse(cacheValue); 29 | return Array.isArray(doc) ? doc.map(d => new this.model(d)) : new this.model(doc); 30 | } 31 | 32 | // Otherwise, issue the query and store the result in redis 33 | const result = await exec.apply(this, ...args); 34 | 35 | client.hset(this.hashKey, key, JSON.stringify(result), 'EX', 10); 36 | 37 | return result; 38 | }; 39 | 40 | export default function clearHash(hashKey) { 41 | client.del(JSON.stringify(hashKey)); 42 | } 43 | -------------------------------------------------------------------------------- /server/utils/constants.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export const REPO_OWNER_TYPES = { 3 | ORGANIZATION: "organization", 4 | USER: "user" 5 | }; 6 | -------------------------------------------------------------------------------- /server/utils/githubapi.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { REPO_OWNER_TYPES } from "./constants"; 3 | 4 | const getRepoOwnerType = async ({ repoOwner }) => { 5 | if (!repoOwner) throw Error("Invalid argument"); 6 | const apiUrl = `${process.env.GITHUB_API_URL}orgs/${repoOwner}`; 7 | 8 | try { 9 | const result = await axios.get(apiUrl); 10 | if (result) { 11 | return REPO_OWNER_TYPES.ORGANIZATION; 12 | } 13 | } catch (e) { 14 | const { response } = e; 15 | 16 | if (response) { 17 | return REPO_OWNER_TYPES.USER; 18 | } 19 | console.log("Get repo owner type error", e); 20 | throw Error("Internal error"); 21 | } 22 | }; 23 | 24 | const checkUserIsACollaborator = async ({ 25 | repoOwner, 26 | projectName, 27 | userName, 28 | accessToken, 29 | }) => { 30 | if (!repoOwner && !projectName && !userName) { 31 | throw Error("Repo name, project name and user name are required"); 32 | } 33 | 34 | const apiUrl = `${process.env.GITHUB_API_URL}repos/${repoOwner}/${projectName}/collaborators/${userName}`; 35 | 36 | try { 37 | await axios.get(apiUrl, { 38 | headers: { Authorization: `token ${accessToken}` }, 39 | }); 40 | 41 | return true; 42 | } catch (error) { 43 | return false; 44 | } 45 | }; 46 | 47 | const checkTokenExpiredOrNot = async ({ accessToken }) => { 48 | const apiUrl = `${process.env.GITHUB_API_URL}user`; 49 | 50 | try { 51 | await axios.get(apiUrl, { 52 | headers: { Authorization: `token ${accessToken}` }, 53 | }); 54 | return true; 55 | } catch (e) { 56 | return false; 57 | } 58 | }; 59 | 60 | export { getRepoOwnerType, checkUserIsACollaborator, checkTokenExpiredOrNot }; 61 | -------------------------------------------------------------------------------- /src/ajax.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable guard-for-in */ 2 | /* |--minAjax.js--| 3 | |--(A Minimalistic Pure JavaScript Header for Ajax POST/GET Request )--| 4 | |--Author : flouthoc (gunnerar7@gmail.com)(http://github.com/flouthoc)--| 5 | |--Contributers : Add Your Name Below--| 6 | */ 7 | function initXMLhttp() { 8 | let xmlhttp; 9 | if (window.XMLHttpRequest) { 10 | // code for IE7,firefox chrome and above 11 | xmlhttp = new XMLHttpRequest(); 12 | } else { 13 | // code for Internet Explorer 14 | xmlhttp = new ActiveXObject('Microsoft.XMLHTTP'); 15 | } 16 | 17 | return xmlhttp; 18 | } 19 | 20 | function minAjax(config) { 21 | /* Config Structure 22 | url:"reqesting URL" 23 | type:"GET or POST" 24 | method: "(OPTIONAL) True for async and False for Non-async | By default its Async" 25 | debugLog: "(OPTIONAL)To display Debug Logs | By default it is false" 26 | data: "(OPTIONAL) another Nested Object which should contains reqested Properties in form of Object Properties" 27 | success: "(OPTIONAL) Callback function to process after response | function(data,status)" 28 | */ 29 | 30 | if (!config.url) { 31 | if (config.debugLog == true) console.log('No Url!'); 32 | return; 33 | } 34 | 35 | if (!config.type) { 36 | if (config.debugLog == true) console.log('No Default type (GET/POST) given!'); 37 | return; 38 | } 39 | 40 | if (!config.method) { 41 | config.method = true; 42 | } 43 | 44 | if (!config.debugLog) { 45 | config.debugLog = false; 46 | } 47 | 48 | const xmlhttp = initXMLhttp(); 49 | 50 | xmlhttp.onreadystatechange = () => { 51 | if (xmlhttp.readyState === 4 && xmlhttp.status == 200) { 52 | if (config.success) { 53 | config.success(xmlhttp.responseText, xmlhttp.readyState); 54 | } 55 | 56 | if (config.debugLog === true) console.log('SuccessResponse'); 57 | if (config.debugLog === true) console.log(`Response Data:${xmlhttp.responseText}`); 58 | } else if (config.debugLog === true) { 59 | console.log(`FailureResponse --> State:${xmlhttp.readyState}Status:${xmlhttp.status}`); 60 | } else if (xmlhttp.readyState === 3 && xmlhttp.status === 400) { 61 | if (config.errorCallback) { 62 | config.errorCallback(JSON.parse(xmlhttp.responseText)); 63 | } 64 | } else if (xmlhttp.readyState === 3 && xmlhttp.status === 401) { 65 | const error = JSON.parse(xmlhttp.responseText); 66 | window.alert(error.message); 67 | window.location.reload(); 68 | chrome.runtime.sendMessage({ logout: true }); 69 | } 70 | }; 71 | 72 | let sendString = []; 73 | const sendData = config.data; 74 | if (typeof sendData === 'string') { 75 | const tmpArr = String.prototype.split.call(sendData, '&'); 76 | for (let i = 0, j = tmpArr.length; i < j; i += 1) { 77 | const datum = tmpArr[i].split('='); 78 | sendString.push(`${encodeURIComponent(datum[0])}=${encodeURIComponent(datum[1])}`); 79 | } 80 | } else if ( 81 | typeof sendData === 'object' && 82 | !(sendData instanceof String || (FormData && sendData instanceof FormData)) 83 | ) { 84 | // eslint-disable-next-line no-restricted-syntax 85 | for (const k in sendData) { 86 | const datum = sendData[k]; 87 | if (Object.prototype.toString.call(datum) === '[object Array]') { 88 | for (let i = 0, j = datum.length; i < j; i += 1) { 89 | sendString.push(`${encodeURIComponent(k)}[]=${encodeURIComponent(datum[i])}`); 90 | } 91 | } else { 92 | sendString.push(`${encodeURIComponent(k)}=${encodeURIComponent(datum)}`); 93 | } 94 | } 95 | } 96 | sendString = sendString.join('&'); 97 | 98 | if (config.type === 'GET') { 99 | xmlhttp.open('GET', `${config.url}?${sendString}`, config.method); 100 | if (config.headers && config.headers.length) { 101 | config.headers.map((header) => { 102 | xmlhttp.setRequestHeader(header.type, header.value); 103 | }); 104 | } 105 | xmlhttp.send(); 106 | 107 | if (config.debugLog === true) console.log(`GET fired at:${config.url}?${sendString}`); 108 | } 109 | if (config.type === 'POST') { 110 | xmlhttp.open('POST', config.url, config.method); 111 | xmlhttp.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); 112 | if (config.headers && config.headers.length) { 113 | config.headers.map((header) => { 114 | xmlhttp.setRequestHeader(header.type, header.value); 115 | }); 116 | } 117 | xmlhttp.send(sendString); 118 | 119 | if (config.debugLog === true) console.log(`POST fired at:${config.url} || Data:${sendString}`); 120 | } 121 | } 122 | 123 | export default minAjax; 124 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | import minAjax from './ajax'; 2 | import { URL, VERSION } from './constants'; 3 | 4 | export const getAllNotes = ({ issueId, projectName, noteType, repoOwner }) => { 5 | try { 6 | return new Promise((resolve, reject) => { 7 | window.chrome.storage.sync.get(['githubPrivateCommentToken'], (result) => { 8 | const authToken = result.githubPrivateCommentToken; 9 | minAjax({ 10 | url: `${URL}${VERSION}/note/all`, // request URL 11 | type: 'POST', 12 | headers: [ 13 | { 14 | type: 'Authorization', 15 | value: `Bearer ${authToken}`, 16 | }, 17 | ], 18 | data: { 19 | issueId, 20 | projectName, 21 | noteType, 22 | repoOwner, 23 | }, 24 | errorCallback(e) { 25 | console.log(e); 26 | reject(e); 27 | }, 28 | success(results) { 29 | // Retrieve all the notes based on issue id 30 | const formattedResults = JSON.parse(results); 31 | const allNotes = formattedResults.data; 32 | resolve(allNotes); 33 | }, 34 | }); 35 | }); 36 | }); 37 | } catch (error) { 38 | return null; 39 | } 40 | }; 41 | // eslint-disable-next-line 42 | export const createNote = ({ 43 | noteContent, 44 | noteType, 45 | issueId, 46 | nearestCommentId, 47 | projectName, 48 | repoOwner, 49 | noteVisibility, 50 | }) => { 51 | try { 52 | return new Promise((resolve, reject) => { 53 | window.chrome.storage.sync.get(['githubPrivateCommentToken'], (result) => { 54 | const authToken = result.githubPrivateCommentToken; 55 | minAjax({ 56 | url: `${URL}${VERSION}/note`, // request URL 57 | type: 'POST', // Request type GET/POST 58 | headers: [ 59 | { 60 | type: 'Authorization', 61 | value: `Bearer ${authToken}`, 62 | }, 63 | ], 64 | data: { 65 | noteContent, 66 | noteType, 67 | issueId, 68 | nearestCommentId, 69 | projectName, 70 | repoOwner, 71 | noteVisibility, 72 | }, 73 | errorCallback(e) { 74 | reject(e); 75 | }, 76 | success(results) { 77 | const formattedResult = JSON.parse(results); 78 | 79 | const newlyCreatedNote = formattedResult.data; 80 | resolve(newlyCreatedNote); 81 | }, 82 | }); 83 | }); 84 | }); 85 | } catch (error) { 86 | console.log(error); 87 | throw error; 88 | } 89 | }; 90 | 91 | // eslint-disable-next-line 92 | export const removeNote = ({ noteId, issueId, projectName, noteType, repoOwner }) => { 93 | try { 94 | return new Promise((resolve) => { 95 | window.chrome.storage.sync.get(['githubPrivateCommentToken'], (result) => { 96 | const authToken = result.githubPrivateCommentToken; 97 | minAjax({ 98 | url: `${URL}${VERSION}/note/delete`, // request URL 99 | type: 'POST', // Request type GET/POST 100 | headers: [ 101 | { 102 | type: 'Authorization', 103 | value: `Bearer ${authToken}`, 104 | }, 105 | ], 106 | data: { 107 | issueId, 108 | projectName, 109 | noteType, 110 | noteId, 111 | repoOwner, 112 | }, 113 | success(results) { 114 | const formattedResult = JSON.parse(results); 115 | const deletedNote = formattedResult.data; 116 | resolve(deletedNote); 117 | }, 118 | }); 119 | }); 120 | }); 121 | } catch (error) { 122 | return null; 123 | } 124 | }; 125 | 126 | // eslint-disable-next-line 127 | export const toggleVisibilityApi = ({ noteId, noteVisibility }) => { 128 | try { 129 | return new Promise((resolve) => { 130 | window.chrome.storage.sync.get(['githubPrivateCommentToken'], (result) => { 131 | const authToken = result.githubPrivateCommentToken; 132 | minAjax({ 133 | url: `${URL}${VERSION}/note/edit`, // request URL 134 | type: 'POST', // Request type GET/POST 135 | headers: [ 136 | { 137 | type: 'Authorization', 138 | value: `Bearer ${authToken}`, 139 | }, 140 | ], 141 | data: { 142 | noteId, 143 | noteVisibility, 144 | }, 145 | success(results) { 146 | const formattedResult = JSON.parse(results); 147 | const updatedNote = formattedResult.data; 148 | resolve(updatedNote); 149 | }, 150 | }); 151 | }); 152 | }); 153 | } catch (error) { 154 | return null; 155 | } 156 | }; 157 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | import { UN_INSTALL_URL, INSTALL_URL } from './constants'; 2 | import { getOauthURL } from './helpers'; 3 | 4 | function openGithubLogin() { 5 | window.chrome.tabs.create({ url: getOauthURL() }); 6 | } 7 | 8 | function checkForAuth() { 9 | const loginBtn = document.getElementById('github-login-btn'); 10 | const logoutBtn = document.getElementById('github-logout-btn'); 11 | const loginMsg = document.getElementById('login-msg'); 12 | const logoutMsg = document.getElementById('loggedout-msg'); 13 | window.chrome.storage.sync.get(['githubPrivateCommentToken'], (result) => { 14 | const authToken = result.githubPrivateCommentToken; 15 | if (!authToken) { 16 | loginBtn.style.display = 'block'; 17 | logoutBtn.style.display = 'none'; 18 | loginMsg.style.display = 'none'; 19 | logoutMsg.style.display = 'block'; 20 | } else { 21 | loginBtn.style.display = 'none'; 22 | logoutBtn.style.display = 'block'; 23 | logoutMsg.style.display = 'none'; 24 | loginMsg.style.display = 'block'; 25 | } 26 | }); 27 | } 28 | 29 | function openGithubLogout() { 30 | window.chrome.storage.sync.remove(['githubPrivateCommentToken'], () => { 31 | checkForAuth(); 32 | }); 33 | } 34 | 35 | document.addEventListener('DOMContentLoaded', () => { 36 | const loginBtn = document.getElementById('github-login-btn'); 37 | const logoutBtn = document.getElementById('github-logout-btn'); 38 | loginBtn.addEventListener('click', () => { 39 | openGithubLogin(); 40 | }); 41 | logoutBtn.addEventListener('click', () => { 42 | openGithubLogout(); 43 | }); 44 | checkForAuth(); 45 | }); 46 | window.chrome.runtime.setUninstallURL(UN_INSTALL_URL); 47 | window.chrome.runtime.onInstalled.addListener((details) => { 48 | if (details.reason === 'install') { 49 | // window.chrome.tabs.create({ url: INSTALL_URL }); 50 | } 51 | }); 52 | 53 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 54 | if (request.logout) openGithubLogout(); 55 | }); 56 | -------------------------------------------------------------------------------- /src/constants.example.js: -------------------------------------------------------------------------------- 1 | export const VERSION = "v1"; 2 | 3 | export const INSTALL_URL = "https://gitxapp.com/connect.html"; 4 | export const UN_INSTALL_URL = "https://gitxapp.com/uninstall.html"; 5 | 6 | export const URL = "http://localhost:5000/api/"; 7 | 8 | export const REDIRECT_URL = "GITHUB_CALLBACK_URL"; 9 | 10 | export const APP_ID = "GITHUB_APP_CLIENT_ID"; 11 | -------------------------------------------------------------------------------- /src/footer.js: -------------------------------------------------------------------------------- 1 | import { getOauthURL } from "./helpers"; 2 | 3 | function createFooter() { 4 | if (window.location.href.includes("//github.com/")) { 5 | const node = document.createElement("div"); 6 | node.innerHTML = `
9 | 10 | 11 | 12 |
`; 13 | const body = document.getElementsByTagName("BODY"); 14 | body[0].after(node); 15 | } 16 | } 17 | 18 | export default createFooter; 19 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import { APP_ID, REDIRECT_URL } from "./constants"; 2 | // eslint-disable-next-line 3 | export const findTimeAgo = ({ date }) => { 4 | const NOW = new Date(); 5 | const times = [ 6 | ["second", 1], 7 | ["minute", 60], 8 | ["hour", 3600], 9 | ["day", 86400], 10 | ["week", 604800], 11 | ["month", 2592000], 12 | ["year", 31536000] 13 | ]; 14 | 15 | let diff = Math.round((NOW - date) / 1000); 16 | 17 | // eslint-disable-next-line 18 | for (let t = 0; t < times.length; t++) { 19 | if (diff < times[t][1]) { 20 | if (t === 0) { 21 | return "Just now"; 22 | } 23 | diff = Math.round(diff / times[t - 1][1]); 24 | 25 | // eslint-disable-next-line 26 | return diff + " " + times[t - 1][0] + (diff === 1 ? " ago" : "s ago"); 27 | } 28 | } 29 | }; 30 | 31 | export const findURLAttributes = ({ currentUrl }) => { 32 | const urlParams = currentUrl.split("/"); 33 | const issueId = urlParams[urlParams.length - 1]; 34 | const noteType = currentUrl.includes("issue") ? "issue" : "pull"; 35 | const projectName = urlParams[urlParams.length - 3]; 36 | const repoOwner = urlParams[urlParams.length - 4]; 37 | 38 | return { 39 | issueId, 40 | noteType, 41 | projectName, 42 | repoOwner 43 | }; 44 | }; 45 | export const checkUrlIsIssueOrPull = ({ URL }) => { 46 | let noteType = ""; 47 | if (URL.includes("issue")) noteType = "issue"; 48 | if (URL.includes("pull")) noteType = "pull"; 49 | 50 | if (noteType === "issue" || noteType === "pull") return true; 51 | return false; 52 | }; 53 | 54 | export const getOauthURL = () => 55 | `https://github.com/login/oauth/authorize?client_id=${APP_ID}&redirect_uri=${REDIRECT_URL}&scope=repo,user:email`; 56 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 38 | 39 | Private Notes 40 | 41 | 42 | 43 |

44 | GitX-Private comments for GitHub 45 |

46 |

47 | Goto github issues/pull request tab to see the private notes. 48 |

49 |

50 | You need to login to your GitHub account to enable this feature. 51 |

52 |
53 | 54 | 55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | // Could break if GitHub changes its markup 3 | import createNoteBox from './noteBox'; 4 | import createFooter from './footer'; 5 | import { getAllNotes, removeNote, createNote, toggleVisibilityApi } from './api'; 6 | import { findURLAttributes, checkUrlIsIssueOrPull } from './helpers'; 7 | import './style.css'; 8 | 9 | let noteContent = ''; 10 | let allNotes = []; 11 | let nearestCommentId = null; 12 | let urlAttributes; 13 | function initUrlAttributes() { 14 | const { 15 | location: { href: currentUrl }, 16 | } = document; 17 | urlAttributes = findURLAttributes({ currentUrl }); 18 | } 19 | // Disable/Enable Add private button based on value entered 20 | function onInputValueChange(e) { 21 | const addPrivateNoteButton = document.getElementById('add_private_note_button'); 22 | noteContent = e.target.value; 23 | if (addPrivateNoteButton) { 24 | if (e.target.value.indexOf('Uploading') !== -1) { 25 | addPrivateNoteButton.disabled = true; 26 | } else if (e.target.value.length > 0 && addPrivateNoteButton) { 27 | addPrivateNoteButton.disabled = false; 28 | } else { 29 | addPrivateNoteButton.disabled = true; 30 | } 31 | } 32 | } 33 | 34 | // Load main input area and add some behaviors 35 | function initInputArea() { 36 | const textArea = document.getElementById('new_comment_field'); 37 | if (textArea) { 38 | textArea.addEventListener('change', (e) => { 39 | onInputValueChange(e); 40 | }); 41 | textArea.addEventListener('input', (e) => { 42 | onInputValueChange(e); 43 | }); 44 | } 45 | } 46 | 47 | async function deleteNote(noteId) { 48 | const { issueId, noteType, projectName, repoOwner } = urlAttributes; 49 | 50 | try { 51 | await removeNote({ 52 | noteId, 53 | noteType, 54 | issueId, 55 | projectName, 56 | repoOwner, 57 | }); 58 | 59 | // Remove deleted note from dom 60 | const commentBoxes = document.querySelectorAll('.private-note'); 61 | commentBoxes.forEach((commentBox) => { 62 | const commentBoxPrivateId = commentBox.getAttribute('private-id'); 63 | if (commentBoxPrivateId === noteId) { 64 | commentBox.remove(); 65 | } 66 | }); 67 | } catch (error) { 68 | console.log('Delete note error:', error); 69 | } 70 | } 71 | 72 | async function toggleVisibility(noteId, noteVisibility) { 73 | try { 74 | await toggleVisibilityApi({ noteId, noteVisibility }); 75 | } catch (error) { 76 | console.log('Toggle visibility note error:', error); 77 | } 78 | } 79 | 80 | function bindDeleteEventToNote(note) { 81 | const deleteBox = document.getElementById(`comment-box-${note._id}`); 82 | 83 | deleteBox.addEventListener('click', () => { 84 | const answer = window.confirm('Are you sure you want to delete this note?'); 85 | 86 | if (answer) { 87 | deleteNote(note._id); 88 | } 89 | }); 90 | } 91 | 92 | function bindToggleVisibilityToNote(note) { 93 | const toggleCheckbox = document.getElementById(`visible-to-all-${note._id}`); 94 | 95 | toggleCheckbox.addEventListener('change', () => { 96 | toggleVisibility(note._id, toggleCheckbox.checked); 97 | }); 98 | } 99 | 100 | // Create add private note button 101 | function createPrivateNoteAddButton() { 102 | const textArea = document.getElementById('new_comment_field'); 103 | 104 | const button = document.createElement('button'); 105 | button.textContent = 'Add a private note'; 106 | button.id = 'add_private_note_button'; 107 | button.type = 'button'; 108 | button.classList.add('btn'); 109 | button.classList.add('btn-primary'); 110 | button.classList.add('ml-1'); 111 | button.disabled = textArea && !textArea.value; 112 | button.onclick = async () => { 113 | button.disabled = true; 114 | let commentBoxes = document.querySelectorAll( 115 | '[data-gid]:not([id]):not(.merge-status-list-wrapper).js-timeline-item', 116 | ); 117 | let extraClass = ''; 118 | let isDiscussionBox = false; 119 | if (commentBoxes.length === 0) { 120 | commentBoxes = document.querySelectorAll('.js-discussion'); 121 | if (commentBoxes.length) { 122 | extraClass = 'ml-0 pl-0 ml-md-6 pl-md-3'; 123 | isDiscussionBox = true; 124 | } 125 | } 126 | const commentBoxCount = commentBoxes.length; 127 | // Find nearest comment id 128 | let nearestBox = commentBoxes[commentBoxCount - 1]; 129 | if (isDiscussionBox) { 130 | const box = nearestBox.firstElementChild; 131 | nearestCommentId = box.getAttribute('data-gid'); 132 | } else { 133 | nearestCommentId = nearestBox.getAttribute('data-gid'); 134 | } 135 | 136 | try { 137 | const { issueId, noteType, projectName, repoOwner } = urlAttributes; 138 | 139 | const newlyCreatedNote = await createNote({ 140 | noteContent, 141 | noteType, 142 | issueId, 143 | nearestCommentId, 144 | projectName, 145 | repoOwner, 146 | noteVisibility: true, 147 | }); 148 | button.disabled = false; 149 | allNotes.push(newlyCreatedNote); 150 | while ( 151 | nearestBox.nextElementSibling && 152 | nearestBox.nextElementSibling.getAttribute('private-id') 153 | ) { 154 | nearestBox = nearestBox.nextSibling; 155 | } 156 | nearestBox.after(createNoteBox(allNotes[allNotes.length - 1], extraClass)); 157 | bindDeleteEventToNote(newlyCreatedNote); 158 | bindToggleVisibilityToNote(newlyCreatedNote); 159 | 160 | textArea.value = ''; 161 | } catch (error) { 162 | window.alert(error.message); 163 | button.disabled = false; 164 | } 165 | }; 166 | const pvtNoteBtn = document.getElementById('add_private_note_button'); 167 | if (!pvtNoteBtn) { 168 | return button; 169 | } 170 | return null; 171 | } 172 | 173 | async function injectContent(apiCall) { 174 | const addedNoteIds = []; 175 | const actionBtns = document.querySelector('#partial-new-comment-form-actions > div'); 176 | let commentBtn = {}; 177 | if (actionBtns) { 178 | [].forEach.call(actionBtns.children, (btn) => { 179 | if (btn.children.length && btn.children[0] && btn.children[0].innerText === 'Comment') { 180 | commentBtn = btn; 181 | } 182 | }); 183 | } 184 | if (commentBtn) { 185 | commentBtn.onclick = () => { 186 | setTimeout(() => { 187 | injectContent(false); 188 | }, 3000); 189 | }; 190 | } 191 | const closeIssueBtn = document.getElementsByName('comment_and_close'); 192 | if (closeIssueBtn && closeIssueBtn.length) { 193 | closeIssueBtn[0].onclick = () => { 194 | setTimeout(() => { 195 | injectContent(false); 196 | }, 3000); 197 | }; 198 | } 199 | initInputArea(); 200 | const positionMarker = document.getElementById('partial-new-comment-form-actions'); 201 | // similar comments hide the gitex comments so opening the collapsible similar comments 202 | const collapsed = document.querySelectorAll('.Details-element.details-reset'); 203 | collapsed.forEach((el) => { 204 | el.setAttribute('open', true); 205 | }); 206 | if (positionMarker) { 207 | const makeANoteBtn = createPrivateNoteAddButton(); 208 | if (makeANoteBtn) { 209 | positionMarker.prepend(makeANoteBtn); 210 | } 211 | if (!apiCall) { 212 | return; 213 | } 214 | try { 215 | const { issueId, noteType, projectName, repoOwner } = urlAttributes; 216 | 217 | // Load all the notes based on issue id 218 | allNotes = await getAllNotes({ 219 | issueId, 220 | projectName, 221 | noteType, 222 | repoOwner, 223 | }); 224 | if (allNotes.length) { 225 | // Iterate all the comments and append notes 226 | let commentBoxes = document.querySelectorAll( 227 | '[data-gid]:not([id]):not(.merge-status-list-wrapper)', 228 | ); 229 | let extraClass = ''; 230 | if (commentBoxes.length === 0) { 231 | commentBoxes = document.querySelectorAll('.js-discussion'); 232 | if (commentBoxes.length) { 233 | extraClass = 'ml-0 pl-0 ml-md-6 pl-md-3'; 234 | } 235 | } 236 | commentBoxes.forEach((commentBox) => { 237 | const commentId = commentBox.getAttribute('data-gid'); 238 | 239 | const findNotesNearestToComment = (obj) => obj.nearestCommentId === commentId; 240 | const notesNearestToCommentBox = allNotes.filter(findNotesNearestToComment); 241 | const sortedNotes = notesNearestToCommentBox.sort( 242 | (a, b) => new Date(b.createdAt) - new Date(a.createdAt), 243 | ); 244 | sortedNotes.forEach((element) => { 245 | const { _id: noteId } = element; 246 | if (!addedNoteIds.includes(noteId)) { 247 | addedNoteIds.push(noteId); 248 | commentBox.after(createNoteBox(element, extraClass)); 249 | if (commentBox) { 250 | bindDeleteEventToNote(element); 251 | bindToggleVisibilityToNote(element); 252 | } 253 | } 254 | }); 255 | }); 256 | } 257 | } catch (error) { 258 | console.log('error', error); 259 | } 260 | } 261 | } 262 | 263 | function init() { 264 | const { 265 | location: { href: URL }, 266 | } = document; 267 | window.chrome.storage.sync.get(['githubPrivateCommentToken'], (result) => { 268 | const authToken = result.githubPrivateCommentToken; 269 | 270 | if (!authToken) { 271 | createFooter(); 272 | } else { 273 | initUrlAttributes(); 274 | if (checkUrlIsIssueOrPull({ URL })) injectContent(true); 275 | } 276 | }); 277 | addSignOutListener(); 278 | } 279 | 280 | window.onload = () => { 281 | init(); 282 | }; 283 | 284 | window.addEventListener('message', (e) => { 285 | if (e.data && e.data.type === 'githubPrivateCommentToken') { 286 | window.chrome.storage.sync.set({ githubPrivateCommentToken: e.data.value }); 287 | } 288 | }); 289 | 290 | (document.body || document.documentElement).addEventListener( 291 | 'transitionend', 292 | () => { 293 | const { 294 | location: { href: URL }, 295 | } = document; 296 | 297 | if (checkUrlIsIssueOrPull({ URL })) { 298 | const privateButton = document.getElementById('add_private_note_button'); 299 | if (!privateButton) init(); 300 | } 301 | }, 302 | true, 303 | ); 304 | function addSignOutListener() { 305 | const logoutBtns = document.querySelectorAll('form[action="/logout"] [type="submit"]'); 306 | const handler = (e) => { 307 | chrome.runtime.sendMessage({ logout: true }); 308 | }; 309 | logoutBtns.forEach((btn) => btn.addEventListener('click', handler)); 310 | } 311 | -------------------------------------------------------------------------------- /src/noteBox.js: -------------------------------------------------------------------------------- 1 | import { findTimeAgo } from './helpers'; 2 | 3 | /* eslint-disable no-underscore-dangle */ 4 | function createCommentBox(noteDetail) { 5 | const { author, createdAt, userDetails } = noteDetail; 6 | const { userName, githubId } = author; 7 | const noteAdmin = githubId.toString() === userDetails.githubId.toString(); 8 | 9 | const createdTimeAgo = findTimeAgo({ date: new Date(createdAt) }); 10 | 11 | const wrapper = document.createElement('div'); 12 | wrapper.classList = [ 13 | 'timeline-comment-group js-minimizable-comment-group js-targetable-comment TimelineItem-body my-0 ', 14 | ]; 15 | 16 | const innerWrapper1 = document.createElement('div'); 17 | innerWrapper1.classList = ['ml-n3 minimized-comment position-relative d-none ']; 18 | 19 | const innerWrapper2 = document.createElement('div'); 20 | innerWrapper2.classList = [ 21 | 'ml-n3 timeline-comment unminimized-comment comment previewable-edit js-task-list-container editable-comment js-comment timeline-comment--caret reorderable-task-lists current-user', 22 | ]; 23 | 24 | const timelineWrapper = document.createElement('div'); 25 | timelineWrapper.classList = ['timeline-comment-header clearfix']; 26 | 27 | const timeLineAction = document.createElement('div'); 28 | timeLineAction.classList = ['timeline-comment-actions js-timeline-comment-actions']; 29 | 30 | const privateNoteLabel = document.createElement('span'); 31 | privateNoteLabel.classList = [ 32 | 'timeline-comment-label tooltipped tooltipped-multiline tooltipped-s pvt-note-label', 33 | ]; 34 | privateNoteLabel.setAttribute('aria-label', 'This is a private note'); 35 | privateNoteLabel.innerText = 'Private note'; 36 | timeLineAction.append(privateNoteLabel); 37 | const timeLineActionDetails = document.createElement('details'); 38 | timeLineActionDetails.classList = [ 39 | 'details-overlay details-reset position-relative d-inline-block', 40 | ]; 41 | timeLineActionDetails.innerHTML = ` 42 | 43 | 44 | 45 | 54 | 55 | 60 | `; 61 | 62 | timeLineAction.append(timeLineActionDetails); 63 | 64 | timelineWrapper.append(timeLineAction); 65 | const timelineH3 = document.createElement('h3'); 66 | timelineH3.classList = ['timeline-comment-header-text f5 text-normal']; 67 | timelineH3.innerHTML = `${userName}`; 68 | const timestamp = document.createElement('span'); 69 | timestamp.classList = ['timestamp js-timestamp']; 70 | 71 | timestamp.innerHTML = ` added ${createdTimeAgo}`; 72 | 73 | timelineH3.append(timestamp); 74 | timelineWrapper.append(timelineH3); 75 | 76 | innerWrapper2.append(timelineWrapper); 77 | const commentBodyWrapper = document.createElement('div'); 78 | commentBodyWrapper.classList = ['edit-comment-hide js-edit-comment-hide']; 79 | const taskList = document.createElement('task-lists'); 80 | taskList.setAttribute('sortable', true); 81 | const table = document.createElement('table'); 82 | table.classList = ['d-block']; 83 | const tbody = document.createElement('tbody'); 84 | tbody.classList = ['d-block']; 85 | const tr = document.createElement('tr'); 86 | tr.classList = ['d-block']; 87 | const td = document.createElement('td'); 88 | td.classList = ['d-block comment-body markdown-body js-comment-body']; 89 | td.innerHTML = noteDetail.noteContent; 90 | tr.appendChild(td); 91 | tbody.appendChild(tr); 92 | table.appendChild(tbody); 93 | taskList.appendChild(table); 94 | commentBodyWrapper.appendChild(taskList); 95 | innerWrapper2.append(commentBodyWrapper); 96 | wrapper.append(innerWrapper1); 97 | wrapper.append(innerWrapper2); 98 | if (!noteAdmin) { 99 | wrapper.getElementsByClassName('btn-link timeline-comment-action')[0].style.visibility = 100 | 'hidden'; 101 | } 102 | 103 | return wrapper; 104 | } 105 | 106 | function createAvatar({ userName, githubId, avatarUrl }) { 107 | const avatarWrapper = document.createElement('div'); 108 | avatarWrapper.classList = ['avatar-parent-child TimelineItem-avatar']; 109 | 110 | // a tag 111 | const avatarA = document.createElement('a'); 112 | avatarA.href = `/${userName}`; 113 | avatarA.classList = ['d-inline-block']; 114 | avatarA.setAttribute('data-hovercard-type', 'user'); 115 | avatarA.setAttribute('data-hovercard-url', `/hovercards?user_id=${githubId}`); 116 | avatarA.setAttribute('data-octo-click', 'hovercard-link-click'); 117 | avatarA.setAttribute('data-octo-dimensions', 'link_type:self'); 118 | 119 | // image tag 120 | const avatarImg = document.createElement('img'); 121 | avatarImg.classList = ['avatar rounded-1']; 122 | avatarImg.height = '44'; 123 | avatarImg.width = '44'; 124 | avatarImg.alt = `@${userName}`; 125 | avatarImg.src = `${avatarUrl}?s=180`; 126 | avatarA.appendChild(avatarImg); 127 | avatarWrapper.appendChild(avatarA); 128 | return avatarWrapper; 129 | } 130 | 131 | export default function createNoteBox(noteDetail, extraClass) { 132 | if (!noteDetail.author) { 133 | noteDetail.author = {}; 134 | } 135 | const { avatarUrl, githubId, userName } = noteDetail.author; 136 | const noteNode = document.createElement('div'); 137 | noteNode.classList = [ 138 | `js-timeline-item js-timeline-progressive-focus-container private-note ${extraClass}`, 139 | ]; 140 | noteNode.setAttribute('private-id', noteDetail._id); 141 | 142 | const noteWrapper = document.createElement('div'); 143 | noteWrapper.classList = ['TimelineItem js-comment-container']; 144 | noteWrapper.appendChild(createAvatar({ userName, githubId, avatarUrl })); 145 | noteWrapper.appendChild(createCommentBox(noteDetail)); 146 | 147 | noteNode.appendChild(noteWrapper); 148 | 149 | return noteNode; 150 | } 151 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .pvt-note-label { 2 | float: left; 3 | background-color: #2cbe4e; 4 | color: white; 5 | float: left; 6 | border-radius: 3px; 7 | padding: 4px 8px; 8 | text-align: center; 9 | margin: 4px 0; 10 | } 11 | 12 | .private-note-status { 13 | background-color: rgb(250, 250, 250); 14 | bottom: 3em; 15 | font-size: 13px; 16 | display: none; 17 | border-width: 1px; 18 | border-style: solid; 19 | border-color: rgb(221, 221, 221); 20 | border-image: initial; 21 | border-radius: 2px; 22 | position: fixed; 23 | z-index: 9999; 24 | right: 3em; 25 | color: rgb(136, 136, 136); 26 | bottom: 1em; 27 | display: inline-block; 28 | font-size: 11px; 29 | padding: 0.5em; 30 | } 31 | 32 | input.visible-to-all { 33 | margin-left: 5px; 34 | } 35 | 36 | label.visible-to-all-text { 37 | font-weight: 400 !important; 38 | } 39 | 40 | div.dropdown-item { 41 | overflow: visible; 42 | } 43 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebPackPlugin = require('html-webpack-plugin'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | const webpack = require('webpack'); 4 | 5 | module.exports = { 6 | entry: { 7 | main: './src/index.js', 8 | background: './src/background.js', 9 | }, 10 | watch: true, 11 | watchOptions: { 12 | aggregateTimeout: 300, 13 | poll: 1000, 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.js$/, 19 | exclude: [/node_modules/], 20 | use: { 21 | loader: 'babel-loader', 22 | }, 23 | }, 24 | { 25 | test: /\.html$/, 26 | use: [ 27 | { 28 | loader: 'html-loader', 29 | options: { minimize: true }, 30 | }, 31 | ], 32 | }, 33 | { 34 | test: /\.css$/, 35 | use: [{ loader: 'style-loader' }, { loader: 'css-loader' }], 36 | }, 37 | ], 38 | }, 39 | plugins: [ 40 | new webpack.IgnorePlugin(/^\.\/background/), 41 | new HtmlWebPackPlugin({ 42 | template: './src/index.html', 43 | filename: './index.html', 44 | }), 45 | new CopyWebpackPlugin([ 46 | { from: 'manifest.json' }, 47 | { from: 'src/background.js' }, 48 | { from: 'icons', to: 'icons' }, 49 | ]), 50 | ], 51 | }; 52 | --------------------------------------------------------------------------------