├── .env.development ├── .env.production ├── .eslintrc ├── .gitignore ├── .prettierignore ├── README.md ├── package.json ├── public ├── index.html └── manifest.json ├── server ├── index.js └── migrations │ ├── 001_add_diffs.sql │ └── 002_add_users.sql ├── src ├── components │ ├── App │ │ └── App.tsx │ ├── Change │ │ ├── Change.css │ │ └── Change.tsx │ ├── Chunk │ │ ├── Chunk.css │ │ └── Chunk.tsx │ ├── Diff │ │ ├── Diff.css │ │ └── Diff.tsx │ ├── File │ │ ├── File.css │ │ └── File.tsx │ ├── Home │ │ ├── Home.css │ │ └── Home.tsx │ └── Nav │ │ ├── Nav.css │ │ └── Nav.tsx ├── index.css ├── index.tsx ├── move.svg ├── react-app-env.d.ts └── registerServiceWorker.js ├── tsconfig.json └── yarn.lock /.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_SERVER_URL=http://localhost:3500 2 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_SERVER_URL=https://api.narrated-diffs.thomasbroadley.com 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "env": { "browser": true, "node": true }, 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:react/recommended", 9 | "plugin:import/errors", 10 | "plugin:import/warnings", 11 | "plugin:import/typescript", 12 | "prettier", 13 | "prettier/react" 14 | ], 15 | "rules": { 16 | "react/prop-types": "off", 17 | "import/order": ["error", { "newlines-between": "always" }], 18 | "import/no-default-export": "error", 19 | "@typescript-eslint/explicit-module-boundary-types": "off" 20 | }, 21 | "settings": { 22 | "react": { 23 | "version": "detect" 24 | }, 25 | "linkComponents": [{ "name": "Link", "linkAttribute": "to" }], 26 | "import/extensions": [".js", ".jsx", ".ts", ".tsx"] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | .env 24 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Narrated Diffs 2 | 3 | A tool to enable PR authors to tell a story with their changes. 4 | 5 | Paste a diff into the tool (you can easily get one by adding `.diff` to the end of a GitHub PR URL, e.g. https://github.com/tbroadley/spellchecker-cli/pull/59.diff). Then you can reorder chunks in the diff and add comments to them, to make it easier for PR reviewers to understand the purpose of your change. 6 | 7 | ## Future work 8 | 9 | ### MLP (minimum loveable product) 10 | 11 | - [x] Store narrated diffs in a database and make them accessible to others 12 | - Store the JSON of the App's `diff` state 13 | - Give diffs unique, unguessable identifiers on creation (on explicit save?) 14 | - [x] Allow pasting in a link to a GitHub PR and initialise a narrated diff for it 15 | - GitHub doesn't allow cross-origin requests, so we'll need to fetch the diff on the server 16 | 17 | ### Later 18 | 19 | - [x] Buttons to move a chunk to the top or bottom of the narrated diff, or up or down one spot 20 | - [x] Read-only mode for review purposes 21 | - [x] GitHub authentication 22 | - [x] Viewing diffs for PRs from private repos 23 | - File tree element on the left 24 | - Filter the chunk list by selecting a file in the tree 25 | - Filter out chunks from a specific file 26 | - Banned files are hidden from the chunk list by default but allow viewing them through the file tree 27 | - See a list of chunks in their original order under each file in the tree, navigate quickly by clicking on them 28 | - Show the position in the file, the context line? 29 | - Allow merging chunks together 30 | - Allow splitting chunks 31 | - Look at how `git add --patch` does it 32 | - Syntax highlighting 33 | - Highlight changed words 34 | - On PR creation, automatically add link to narrated diff in PR description, or in a comment 35 | - On PR merge, automatically add link to narrated diff to the merge commit and lock the narrated diff 36 | - Webhook from GitHub to Narrated Diffs backend that is sent on PR merge? 37 | - GitHub Actions? 38 | - Permissions system (authenticate with GitHub/GitLab?) 39 | - Create a browser extension that makes it easy to initialise narrated diffs from a GitHub PR page 40 | - Display comments from GitHub 41 | - Allow adding comments to GitHub from Narrated Diffs 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "literate-diffs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@hapi/bell": "^12.1.1", 8 | "@hapi/cookie": "^11.0.2", 9 | "@hapi/hapi": "^20.0.1", 10 | "dotenv": "^8.2.0", 11 | "eslint-config-prettier": "^6.12.0", 12 | "lodash": "^4.17.20", 13 | "node-fetch": "^2.6.1", 14 | "parse-diff": "^0.4.0", 15 | "pg": "^8.3.3", 16 | "react": "^16.13.1", 17 | "react-dom": "^16.13.1", 18 | "react-quill": "^1.3.5", 19 | "react-router": "^5.2.0", 20 | "react-router-dom": "^5.2.0", 21 | "react-scripts": "^3.4.3", 22 | "react-sortable-hoc": "^0.7.4", 23 | "uuid": "^8.3.0" 24 | }, 25 | "scripts": { 26 | "serve": "DOTENV_CONFIG_PATH=server/.env nodemon -r dotenv/config server", 27 | "start": "react-scripts start", 28 | "build": "react-scripts build", 29 | "test": "react-scripts test --env=jsdom", 30 | "lint": "eslint -c .eslintrc src/**/*.ts src/**/*.tsx", 31 | "lint:fix": "eslint --fix -c .eslintrc src/**/*.ts src/**/*.tsx", 32 | "format": "prettier --write .", 33 | "eject": "react-scripts eject" 34 | }, 35 | "husky": { 36 | "hooks": { 37 | "pre-commit": "pretty-quick --staged && npm run lint:fix" 38 | } 39 | }, 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | }, 52 | "homepage": "https://narrated-diffs.thomasbroadley.com", 53 | "devDependencies": { 54 | "@types/lodash": "^4.14.161", 55 | "@types/node": "^14.11.2", 56 | "@types/react": "^16.9.49", 57 | "@types/react-dom": "^16.9.8", 58 | "@types/react-router-dom": "^5.1.5", 59 | "@typescript-eslint/eslint-plugin": "4.0.1", 60 | "@typescript-eslint/parser": "4.0.1", 61 | "eslint-plugin-import": "^2.22.0", 62 | "eslint-plugin-react": "^7.21.2", 63 | "husky": "^4.3.0", 64 | "nodemon": "^2.0.4", 65 | "prettier": "^2.1.2", 66 | "pretty-quick": "^3.0.2", 67 | "typescript": "^4.0.3" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | Narrated Diffs 16 | 17 | 18 | 19 |
20 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Narrative Diffs", 3 | "name": "Narrative Diffs", 4 | "start_url": "./index.html", 5 | "display": "standalone", 6 | "theme_color": "#333333", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import Hapi from "@hapi/hapi"; 2 | import Bell from "@hapi/bell"; 3 | import Cookie from "@hapi/cookie"; 4 | import fetch from "node-fetch"; 5 | import pg from "pg"; 6 | import { v4 as uuidv4 } from "uuid"; 7 | 8 | const { 9 | NODE_ENV, 10 | SERVER_URL, 11 | PUBLIC_URL, 12 | COOKIE_PASSWORD, 13 | GITHUB_CLIENT_ID, 14 | GITHUB_CLIENT_SECRET, 15 | } = process.env; 16 | 17 | const pool = new pg.Pool(); 18 | 19 | const init = async () => { 20 | const server = Hapi.server({ 21 | port: 3500, 22 | host: "localhost", 23 | routes: { 24 | cors: { 25 | origin: 26 | NODE_ENV === "production" 27 | ? ["https://narrated-diffs.thomasbroadley.com"] 28 | : ["*"], 29 | credentials: true, 30 | }, 31 | }, 32 | }); 33 | 34 | await server.register(Bell); 35 | await server.register(Cookie); 36 | 37 | server.auth.strategy("github", "bell", { 38 | provider: "github", 39 | password: COOKIE_PASSWORD, 40 | clientId: GITHUB_CLIENT_ID, 41 | clientSecret: GITHUB_CLIENT_SECRET, 42 | scope: ["user", "repo"], 43 | isSecure: false, // In production, apache2 rewrites Set-Cookie headers to create secure cookies 44 | location: SERVER_URL, 45 | }); 46 | 47 | server.auth.strategy("session", "cookie", { 48 | cookie: { 49 | password: COOKIE_PASSWORD, 50 | isSecure: false, // In production, apache2 rewrites Set-Cookie headers to create secure cookies 51 | isSameSite: NODE_ENV === "production" && "Strict", 52 | path: "/", 53 | }, 54 | validateFunc: async (_, session) => { 55 | const response = await pool.query("SELECT * FROM users WHERE id = $1", [ 56 | session.id, 57 | ]); 58 | 59 | if (!response.rowCount === 0) { 60 | return { valid: false }; 61 | } 62 | 63 | return { valid: true, credentials: response.rows[0] }; 64 | }, 65 | }); 66 | 67 | server.route({ 68 | method: ["GET", "POST"], 69 | path: "/users/login", 70 | options: { 71 | auth: { 72 | mode: "try", 73 | strategy: "github", 74 | }, 75 | handler: async (request, h) => { 76 | try { 77 | const { auth } = request; 78 | 79 | if (!auth.isAuthenticated) { 80 | return h 81 | .response(`GitHub authentication failed: ${auth.error.message}`) 82 | .code(401); 83 | } 84 | 85 | // TODO handle refresh tokens 86 | const { 87 | token, 88 | profile: { id: githubId, username }, 89 | } = auth.credentials; 90 | 91 | let response = await pool.query( 92 | "SELECT * FROM users WHERE github_id = $1", 93 | [githubId] 94 | ); 95 | 96 | if (response.rowCount === 0) { 97 | await pool.query( 98 | "INSERT INTO users (github_id, github_username, github_token) values ($1, $2, $3)", 99 | [githubId, username, token] 100 | ); 101 | response = await pool.query( 102 | "SELECT * FROM users WHERE github_id = $1", 103 | [githubId] 104 | ); 105 | } else { 106 | await pool.query( 107 | "UPDATE users set github_username = $1, github_token = $2 where github_id = $3", 108 | [username, token, githubId] 109 | ); 110 | response = await pool.query( 111 | "SELECT * FROM users WHERE github_id = $1", 112 | [githubId] 113 | ); 114 | } 115 | 116 | request.cookieAuth.set({ id: response.rows[0].id }); 117 | 118 | return h.redirect(PUBLIC_URL); 119 | } catch (e) { 120 | console.error(e); 121 | } 122 | }, 123 | }, 124 | }); 125 | 126 | server.route({ 127 | method: "GET", 128 | path: "/users/logout", 129 | options: { 130 | auth: "session", 131 | }, 132 | handler: (request, h) => { 133 | request.cookieAuth.clear(); 134 | return h.redirect(PUBLIC_URL); 135 | }, 136 | }); 137 | 138 | server.route({ 139 | method: "GET", 140 | path: "/users/current", 141 | options: { 142 | auth: "session", 143 | }, 144 | handler: async (request, h) => { 145 | const { credentials } = request.auth; 146 | if (!credentials) return h.response().code(401); 147 | 148 | return { githubUsername: credentials.github_username }; 149 | }, 150 | }); 151 | 152 | server.route({ 153 | method: "GET", 154 | path: "/github-diff", 155 | options: { 156 | auth: { 157 | strategy: "session", 158 | mode: "try", 159 | }, 160 | }, 161 | handler: async (request, h) => { 162 | const { credentials } = request.auth; 163 | 164 | let response; 165 | 166 | const { url, owner, repo, pull_number: pullNumber } = request.query; 167 | if (url) { 168 | // TODO add validation to only fetch diffs, e.g. it has to end with .diff 169 | response = await fetch(url); 170 | } else { 171 | response = await fetch( 172 | `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`, 173 | { 174 | headers: { 175 | accept: "application/vnd.github.diff", 176 | Authorization: 177 | credentials?.github_token && 178 | `token ${credentials.github_token}`, 179 | }, 180 | } 181 | ); 182 | } 183 | return h 184 | .response(await response.text()) 185 | .code(response.status) 186 | .type("text/plain"); 187 | }, 188 | }); 189 | 190 | server.route({ 191 | method: "GET", 192 | path: "/diffs/{id}", 193 | handler: async (request, h) => { 194 | const { id } = request.params; 195 | 196 | const response = await pool.query("SELECT * FROM diffs WHERE id = $1", [ 197 | id, 198 | ]); 199 | if (response.rowCount === 0) { 200 | return h.response().code(404); 201 | } 202 | 203 | const { diff } = response.rows[0]; 204 | return { id, diff: JSON.parse(diff) }; 205 | }, 206 | }); 207 | 208 | server.route({ 209 | method: "POST", 210 | path: "/diffs", 211 | handler: async (request) => { 212 | const diff = request.payload.diff; 213 | const id = uuidv4(); 214 | 215 | await pool.query("INSERT INTO diffs (id, diff) VALUES ($1, $2)", [ 216 | id, 217 | JSON.stringify(diff), 218 | ]); 219 | return { id, diff }; 220 | }, 221 | }); 222 | 223 | server.route({ 224 | method: "PATCH", 225 | path: "/diffs/{id}", 226 | handler: async (request) => { 227 | const { id, diff } = request.payload; 228 | 229 | await pool.query("UPDATE diffs SET diff = $1 WHERE id = $2", [ 230 | JSON.stringify(diff), 231 | id, 232 | ]); 233 | return { id, diff }; 234 | }, 235 | }); 236 | 237 | await server.start(); 238 | console.log("Server running on %s", server.info.uri); 239 | }; 240 | 241 | process.on("unhandledRejection", (err) => { 242 | console.log(err); 243 | process.exit(1); 244 | }); 245 | 246 | init(); 247 | -------------------------------------------------------------------------------- /server/migrations/001_add_diffs.sql: -------------------------------------------------------------------------------- 1 | create table diffs 2 | ( 3 | id uuid primary key, 4 | diff text not null default '[]' 5 | ) 6 | -------------------------------------------------------------------------------- /server/migrations/002_add_users.sql: -------------------------------------------------------------------------------- 1 | create table users 2 | ( 3 | id bigserial primary key, 4 | github_id bigint not null, 5 | github_username varchar(39) not null, 6 | github_token varchar(100) not null, 7 | constraint unq_github_id unique (github_id) 8 | ) 9 | -------------------------------------------------------------------------------- /src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, BrowserRouter as Router, Switch } from "react-router-dom"; 3 | 4 | import { Home } from "../Home/Home"; 5 | import { Diff } from "../Diff/Diff"; 6 | import { Nav } from "../Nav/Nav"; 7 | 8 | const { REACT_APP_SERVER_URL } = process.env; 9 | 10 | export function App() { 11 | const [username, setUsername] = React.useState(undefined); 12 | 13 | React.useEffect(() => { 14 | (async () => { 15 | const response = await fetch(`${REACT_APP_SERVER_URL}/users/current`, { 16 | credentials: "include", 17 | }); 18 | const { githubUsername } = await response.json(); 19 | setUsername(githubUsername); 20 | })(); 21 | }, []); 22 | 23 | return ( 24 | 25 | 26 | 27 | {({ match }) => ( 28 |
29 |
32 | )} 33 |
34 | 35 | {({ match }) => ( 36 |
37 |
40 | )} 41 |
42 | 43 |