├── tsconfig.json ├── _firebase ├── remoteconfig.template.json ├── storage.rules ├── firestore.indexes.json └── firestore.rules ├── web ├── .npmrc ├── server │ └── tsconfig.json ├── public │ ├── favicon.ico │ ├── chrlschn.jpg │ ├── coderev-og.png │ ├── images │ │ ├── candidate-fail.png │ │ ├── n-queens-rating.png │ │ ├── n-queens-solved.gif │ │ ├── n-queens-solved.mp4 │ │ ├── hand-crafted-nails.png │ │ └── oreilly-operators.png │ └── screens │ │ ├── coderev-files.webp │ │ ├── coderev-comments.webp │ │ ├── coderev-candidates.webp │ │ └── coderev-workspaces.webp ├── tsconfig.json ├── layouts │ ├── empty.vue │ ├── app-layout.vue │ └── default.vue ├── components │ ├── AnalyticsWrapper.vue │ ├── QuasarImage.vue │ ├── QuasarVideo.vue │ ├── DeleteConfirmButton.vue │ ├── LeftNavBottomButtons.vue │ ├── NewWorkspaceDialog.vue │ ├── workspace │ │ ├── WorkspaceFileSelectorDialog.vue │ │ ├── WorkspaceTeamDialog.vue │ │ ├── WorkspaceFileItem.vue │ │ ├── WorkspaceRatingDialog.vue │ │ ├── WorkspaceCandidateDialog.vue │ │ └── WorkspaceComments.vue │ ├── PreferencesDialog.vue │ └── SideDialogShell.vue ├── utils │ ├── commonProps.ts │ ├── environment.ts │ ├── nanoid.ts │ └── data │ │ ├── FirebaseSubscriptions.ts │ │ ├── Storage.ts │ │ └── Repository.ts ├── .gitignore ├── middleware │ └── auth.ts ├── content.config.ts ├── .env.template ├── quasar-variables.sass ├── composables │ ├── useCommandPalette.ts │ └── useProfileOptions.ts ├── app.vue ├── nuxt.config.ts ├── pages │ ├── blog.vue │ ├── workspace │ │ └── [uid] │ │ │ └── c │ │ │ └── [candidateUid].vue │ ├── blog │ │ └── [slug].vue │ ├── login.vue │ ├── review │ │ └── [uid].vue │ └── home.vue ├── package.json ├── content │ ├── 7-strategies-for-using-code-reviews-in-technical-interviews.md │ ├── ais-coming-industrialization-of-coding-how-teams-need-to-rethink-hiring.md │ ├── improving-your-interview-process-buy-the-pecans-skip-the-trail-mix.md │ └── the-impact-of-ai-on-the-technical-interview-process.md └── stores │ ├── appStore.ts │ ├── composables │ └── candidates.ts │ └── workspaceStore.ts ├── .firebaserc ├── functions ├── tsconfig.dev.json ├── .gitignore ├── src │ ├── index.ts │ ├── ServerFirebaseConnector.ts │ └── generateAccount.ts ├── tsconfig.json ├── .eslintrc.js └── package.json ├── .vscode └── settings.json ├── package.json ├── yarn.lock ├── shared ├── constants.ts ├── messageModels.ts ├── viewModels.ts ├── domainModels.ts └── models.ts ├── .gitignore ├── .github └── workflows │ └── firebase-hosting-merge.yml ├── firebase.json └── README.md /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | } -------------------------------------------------------------------------------- /_firebase/remoteconfig.template.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /web/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "coderev-app" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /web/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /functions/tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | ".eslintrc.js" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/chrlschn.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/chrlschn.jpg -------------------------------------------------------------------------------- /web/public/coderev-og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/coderev-og.png -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /web/public/images/candidate-fail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/images/candidate-fail.png -------------------------------------------------------------------------------- /web/public/images/n-queens-rating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/images/n-queens-rating.png -------------------------------------------------------------------------------- /web/public/images/n-queens-solved.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/images/n-queens-solved.gif -------------------------------------------------------------------------------- /web/public/images/n-queens-solved.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/images/n-queens-solved.mp4 -------------------------------------------------------------------------------- /web/public/screens/coderev-files.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/screens/coderev-files.webp -------------------------------------------------------------------------------- /web/public/images/hand-crafted-nails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/images/hand-crafted-nails.png -------------------------------------------------------------------------------- /web/public/images/oreilly-operators.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/images/oreilly-operators.png -------------------------------------------------------------------------------- /web/public/screens/coderev-comments.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/screens/coderev-comments.webp -------------------------------------------------------------------------------- /web/public/screens/coderev-candidates.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/screens/coderev-candidates.webp -------------------------------------------------------------------------------- /web/public/screens/coderev-workspaces.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CharlieDigital/coderev/HEAD/web/public/screens/coderev-workspaces.webp -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Archivable" 4 | ], 5 | "vue3snippets.enable-compile-vue-file-on-did-save-code": true 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "fuse.js": "^7.1.0" 4 | }, 5 | "packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447" 6 | } 7 | -------------------------------------------------------------------------------- /web/layouts/empty.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled JavaScript files 2 | lib/**/*.js 3 | lib/**/*.js.map 4 | 5 | # TypeScript v1 declaration files 6 | typings/ 7 | 8 | # Node.js dependency directory 9 | node_modules/ 10 | -------------------------------------------------------------------------------- /web/components/AnalyticsWrapper.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 9 | 10 | -------------------------------------------------------------------------------- /web/utils/commonProps.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * File which contains common props 3 | */ 4 | 5 | export const btnProps = { 6 | noCaps: true 7 | } 8 | 9 | export const tabProps = { 10 | noCaps: true 11 | } 12 | 13 | export const leftMenuProps = { 14 | class: "rounded-borders", 15 | }; -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import {generateAccount} from "./generateAccount"; 2 | import {firebaseConnector} from "./ServerFirebaseConnector"; 3 | 4 | console.log("Starting functions..."); 5 | 6 | // Initialize the Firebase Admin SDK 7 | firebaseConnector.start(); 8 | 9 | exports.generateAccount = generateAccount; 10 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .nuxt 4 | .nitro 5 | .cache 6 | dist 7 | 8 | # Node dependencies 9 | node_modules 10 | 11 | # Logs 12 | logs 13 | *.log 14 | 15 | # Misc 16 | .DS_Store 17 | .fleet 18 | .idea 19 | 20 | # Local env files 21 | .env 22 | .env.* 23 | !.env.example 24 | !.env.template 25 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | fuse.js@^7.1.0: 6 | version "7.1.0" 7 | resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.1.0.tgz#306228b4befeee11e05b027087c2744158527d09" 8 | integrity sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ== 9 | -------------------------------------------------------------------------------- /web/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | 2 | export default defineNuxtRouteMiddleware(async (to, from) => { 3 | console.log(" 🔑 Executing auth middleware.") 4 | 5 | const { user } = useAppStore() 6 | 7 | if (!user && to.path !== `/review/${demoCandidateId}`) { 8 | console.log(" 🔑 No user present; redirecting to login.") 9 | 10 | return { name: 'login', query: {redirect: to.fullPath}} 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "noImplicitReturns": true, 6 | "noUnusedLocals": true, 7 | "outDir": "lib", 8 | "sourceMap": true, 9 | "strict": true, 10 | "target": "es2017", 11 | "skipLibCheck": true 12 | }, 13 | "compileOnSave": true, 14 | "include": [ 15 | "src" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /web/utils/environment.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Returns the base URL depending on whether we are in a dev environment or not. 4 | */ 5 | export const baseUrl = import.meta.env.DEV 6 | ? "http://localhost:3000" 7 | : import.meta.env.VITE_PUBLISHED_BASE_URL 8 | 9 | /** 10 | * The ID of the demo workspace. 11 | */ 12 | export const demoWorkspaceId = "DWJWmHIdofurrJtu" 13 | 14 | /** 15 | * ID of a designated demo candidate. 16 | */ 17 | export const demoCandidateId = "wN0zcH1PNnXeVJWW" 18 | -------------------------------------------------------------------------------- /web/utils/nanoid.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid' 2 | import { alphanumeric } from 'nanoid-dictionary' 3 | 4 | /** 5 | * Creates an alphanumeric ID string using the specified number of characters. 6 | * The default is to create an ID of 8 characters if no length is specified. 7 | * @param size An optional integer which defines the size of the ID string. 8 | */ 9 | export const nanoid = (size?: number | undefined): string => { 10 | const characterCount = size ?? 16 11 | return customAlphabet(alphanumeric, characterCount)() 12 | } 13 | -------------------------------------------------------------------------------- /web/content.config.ts: -------------------------------------------------------------------------------- 1 | import { defineContentConfig, defineCollection, z } from '@nuxt/content' 2 | import { asSitemapCollection } from '@nuxtjs/sitemap/content' 3 | 4 | export default defineContentConfig({ 5 | collections: { 6 | blog: defineCollection(asSitemapCollection({ 7 | type: 'page', 8 | source: { 9 | include: '**/*.md', 10 | prefix: '/blog/', 11 | }, 12 | schema: z.object({ 13 | author: z.string(), 14 | date: z.string(), 15 | ogImage: z.string().optional() 16 | }) 17 | })) 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /web/.env.template: -------------------------------------------------------------------------------- 1 | # Firebase configuration options. 2 | VITE_FIREBASE_API_KEY="AIz******************************tpd8" 3 | VITE_FIREBASE_AUTH_DOMAIN="coderev.app" 4 | VITE_FIREBASE_PROJECT_ID="coderev-app" 5 | VITE_FIREBASE_STORAGE_BUCKET="coderev-app.appspot.com" 6 | VITE_FIREBASE_MESSAGING_SENDER_ID="4554********" 7 | VITE_FIREBASE_APP_ID="1:4554********:web:26e599838f**********09b" 8 | VITE_FIREBASE_MEASUREMENT_ID="G-R******R" 9 | VITE_FIREBASE_SOURCE_STORAGE_BUCKET="source.coderev.app" 10 | 11 | # Base URL of the published application. 12 | VITE_PUBLISHED_BASE_URL="https://coderev.app" -------------------------------------------------------------------------------- /web/components/QuasarImage.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /web/components/QuasarVideo.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const STORAGE_PREFIX = "cr_"; 2 | 3 | export const SCHEMA_VERSION = "v0"; 4 | 5 | export const ALLOWED_IMAGE_FILE_EXTENSIONS = ".jpg.jpeg.png.avif.webp"; 6 | 7 | export const ALLOWED_CODE_FILE_EXTENSIONS = [ 8 | ".c", 9 | ".cpp", 10 | ".cs", 11 | ".css", 12 | ".csv", 13 | ".dart", 14 | ".go", 15 | ".html", 16 | ".js", 17 | ".ts", 18 | ".h", 19 | ".hpp", 20 | ".java", 21 | ".jl", 22 | ".json", 23 | ".jsx", 24 | ".kt", 25 | ".md", 26 | ".php", 27 | ".py", 28 | ".rb", 29 | ".rs", 30 | ".sass", 31 | ".scala", 32 | ".scss", 33 | ".sql", 34 | ".swift", 35 | ".toml", 36 | ".tsx", 37 | ".txt", 38 | ".vue", 39 | ".xhtml", 40 | ".xml", 41 | ".xsl", 42 | ".xslt", 43 | ".yaml", 44 | ]; 45 | -------------------------------------------------------------------------------- /web/quasar-variables.sass: -------------------------------------------------------------------------------- 1 | // @import url('https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@300;500;700;900&display=swap') 2 | 3 | // $typography-font-family : 'M Plus Rounded 1c', sans-serif !default 4 | $typography-font-family : 'M PLUS 2', sans-serif !default 5 | 6 | $primary : #333 7 | $secondary : #eceade 8 | $accent : #512da8 9 | 10 | $dark : #2a2a2a 11 | $dark-page : #2a2a2a 12 | 13 | $positive : #44673b 14 | $negative : #bf283a 15 | $info : #b5bbc3 16 | $warning : #F2C037 17 | 18 | $generic-border-radius : 12px 19 | $button-border-radius : 12px 20 | $shadow-color: #333 21 | $dark-shadow-color: #000 22 | 23 | // Fix issues with font rendering in Windows with sub-tenth values. 24 | $body2: (size: 0.9rem) 25 | $h6: (size: 1.3rem, weight: 700) 26 | -------------------------------------------------------------------------------- /functions/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:import/errors", 10 | "plugin:import/warnings", 11 | "plugin:import/typescript", 12 | "google", 13 | "plugin:@typescript-eslint/recommended", 14 | ], 15 | parser: "@typescript-eslint/parser", 16 | parserOptions: { 17 | project: ["tsconfig.json", "tsconfig.dev.json"], 18 | sourceType: "module", 19 | }, 20 | ignorePatterns: [ 21 | "/lib/**/*", // Ignore built files. 22 | ], 23 | plugins: [ 24 | "@typescript-eslint", 25 | "import", 26 | ], 27 | rules: { 28 | "quotes": ["error", "double"], 29 | "import/no-unresolved": 0, 30 | "indent": ["error", 2], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /_firebase/storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | 3 | // Craft rules based on data in your Firestore database 4 | // allow write: if firestore.get( 5 | // /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin; 6 | service firebase.storage { 7 | match /b/{bucket}/o { 8 | match /{allPaths=**} { 9 | allow read, write: if false; 10 | } 11 | } 12 | 13 | // The ruleset for the source bucket. 14 | match /b/source.coderev.app/o { 15 | match /{workspaceUid}/{source} { 16 | // This workspace is designated as a demo. 17 | allow read: if (request.auth == null && workspaceUid == 'DWJWmHIdofurrJtu') 18 | || request.auth != null 19 | 20 | allow write: if request.auth != null 21 | && request.auth.uid in firestore.get(/databases/(default)/documents/workspaces/$(workspaceUid)).data.collaborators 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/composables/useCommandPalette.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "quasar" 2 | 3 | export const modifier = Platform.is.mac ? "Meta" : "Control"; 4 | 5 | /** 6 | * Composable for the command palette 7 | */ 8 | function useCommandPalette() { 9 | const showFileSelector = ref(false) 10 | 11 | function handleKeypress(e: KeyboardEvent) { 12 | if (!window.location.href.includes("/c/") && !window.location.href.includes("/review/")) { 13 | return; 14 | } 15 | 16 | if (e.key === "p" && e.getModifierState(modifier)) { 17 | e.preventDefault(); 18 | showFileSelector.value = !showFileSelector.value; 19 | } 20 | } 21 | 22 | console.log("Connected event listener for keypress") 23 | 24 | window.addEventListener("keydown", handleKeypress); 25 | 26 | return { 27 | showFileSelector 28 | } 29 | } 30 | 31 | export const palette = useCommandPalette() 32 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "lint": "eslint --ext .js,.ts .", 5 | "build": "tsc", 6 | "build:watch": "tsc --watch", 7 | "serve": "npm run build && firebase emulators:start --only functions", 8 | "shell": "npm run build && firebase functions:shell", 9 | "start": "npm run shell", 10 | "deploy": "firebase deploy --only functions", 11 | "logs": "firebase functions:log" 12 | }, 13 | "engines": { 14 | "node": "20" 15 | }, 16 | "main": "lib/functions/src/index.js", 17 | "dependencies": { 18 | "firebase-admin": "^13.0.2", 19 | "firebase-functions": "^6.3.1" 20 | }, 21 | "devDependencies": { 22 | "@typescript-eslint/eslint-plugin": "^5.12.0", 23 | "@typescript-eslint/parser": "^5.12.0", 24 | "eslint": "^8.9.0", 25 | "eslint-config-google": "^0.14.0", 26 | "eslint-plugin-import": "^2.25.4", 27 | "firebase-functions-test": "^3.4.0", 28 | "typescript": "^4.9.0" 29 | }, 30 | "private": true 31 | } 32 | -------------------------------------------------------------------------------- /shared/messageModels.ts: -------------------------------------------------------------------------------- 1 | import { EmbeddedRef } from "./models"; 2 | 3 | /** 4 | * The base response type. 5 | */ 6 | export type ResponseBase = { 7 | /** 8 | * True when the operation completes without error 9 | */ 10 | succeeded: boolean; 11 | /** 12 | * A message returned to the UI. 13 | */ 14 | message: string; 15 | }; 16 | 17 | /** 18 | * Request to generate an account for an anonymous user. 19 | */ 20 | export type GenerateAccountRequest = { 21 | /** 22 | * The generated username for this user. 23 | */ 24 | username: string, 25 | /** 26 | * A label or n ame for the user. 27 | */ 28 | label: string, 29 | /** 30 | * The generated password for the account. 31 | */ 32 | password: string, 33 | /** 34 | * The workspace UID that this generated account was created for. 35 | */ 36 | workspaceUid: string, 37 | /** 38 | * The ref to the user that created this account. 39 | */ 40 | createdBy: EmbeddedRef 41 | } 42 | 43 | /** 44 | * Response to the account generation request. 45 | */ 46 | export type GenerateAccountResponse = { 47 | 48 | } & ResponseBase 49 | -------------------------------------------------------------------------------- /web/components/DeleteConfirmButton.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /web/app.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 36 | 37 | 65 | 66 | 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | firebase-debug.*.log* 9 | 10 | # Firebase cache 11 | .firebase/ 12 | 13 | # Firebase config 14 | 15 | # Uncomment this if you'd like others to create their own Firebase project. 16 | # For a team working on the same Firebase project(s), it is recommended to leave 17 | # it commented so all members can deploy to the same project(s) in .firebaserc. 18 | # .firebaserc 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .data/ 68 | 69 | .DS_store -------------------------------------------------------------------------------- /_firebase/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "candidates", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { "fieldPath": "workspaceUid", "order": "ASCENDING" }, 8 | { "fieldPath": "uid", "order": "ASCENDING" }, 9 | { "fieldPath": "email", "order": "ASCENDING" }, 10 | { "fieldPath": "createdAtUtc", "order": "DESCENDING" } 11 | ] 12 | }, 13 | { 14 | "collectionGroup": "candidates", 15 | "queryScope": "COLLECTION", 16 | "fields": [ 17 | { "fieldPath": "email", "order": "ASCENDING" }, 18 | { "fieldPath": "createdAtUtc", "order": "DESCENDING" } 19 | ] 20 | }, 21 | { 22 | "collectionGroup": "candidates", 23 | "queryScope": "COLLECTION", 24 | "fields": [ 25 | { "fieldPath": "workspaceUid", "order": "ASCENDING" }, 26 | { "fieldPath": "createdAtUtc", "order": "DESCENDING" } 27 | ] 28 | } 29 | ], 30 | "fieldOverrides": [ 31 | { 32 | "collectionGroup": "workspaces", 33 | "fieldPath": "sources", 34 | "indexes": [] 35 | }, 36 | { 37 | "collectionGroup": "workspaces", 38 | "fieldPath": "ratings", 39 | "indexes": [] 40 | }, 41 | { 42 | "collectionGroup": "candidates", 43 | "fieldPath": "sources", 44 | "indexes": [] 45 | }, 46 | { 47 | "collectionGroup": "candidates", 48 | "fieldPath": "comments", 49 | "indexes": [] 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-merge.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on merge 5 | 'on': 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | build_and_deploy: 11 | runs-on: ubuntu-latest 12 | environment: prod 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20' 20 | cache: 'yarn' 21 | cache-dependency-path: ./web/yarn.lock 22 | 23 | - run: | 24 | pushd web 25 | echo "${{ secrets.ENV_FILE }}" > .env 26 | yarn install --immutable --immutable-cache 27 | yarn generate 28 | popd 29 | 30 | # - uses: FirebaseExtended/action-hosting-deploy@v0 31 | # with: 32 | # repoToken: '${{ secrets.GITHUB_TOKEN }}' 33 | # firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_CODEREV_APP }}' 34 | # channelId: live 35 | # projectId: coderev-app 36 | 37 | - name: Build Functions 38 | working-directory: functions 39 | run: | 40 | npm ci --prefer-offline --no-audit --no-progress 41 | npm run build 42 | if [ $? != 0 ]; then 43 | echo "Build failed" 44 | exit 1 45 | fi 46 | 47 | - name: Deploy Hosting and Firebase Rules 48 | uses: w9jds/firebase-action@v13.29.3 49 | with: 50 | args: deploy --only firestore,hosting,functions,storage 51 | env: 52 | GCP_SA_KEY: ${{ secrets.GCP_CREDENTIALS_JSON }} 53 | -------------------------------------------------------------------------------- /functions/src/ServerFirebaseConnector.ts: -------------------------------------------------------------------------------- 1 | import * as admin from "firebase-admin"; 2 | import {getAuth} from "firebase-admin/auth"; 3 | import {getFirestore} from "firebase-admin/firestore"; 4 | import {getStorage} from "firebase-admin/storage"; 5 | 6 | /** 7 | * Class which encapsulates common Firebase components. 8 | */ 9 | class ServerFirebaseConnector { 10 | private readonly app; 11 | private readonly firestore; 12 | private readonly backingStorage; 13 | private readonly _auth; 14 | 15 | /** 16 | * Initializes the Firestore connection. 17 | */ 18 | constructor() { 19 | this.app = admin.initializeApp(); 20 | this.firestore = getFirestore(this.app); 21 | this.backingStorage = getStorage(this.app); 22 | this._auth = getAuth(this.app); 23 | 24 | this.firestore.settings({ignoreUndefinedProperties: true}); 25 | } 26 | 27 | /** 28 | * Just logs the start. 29 | */ 30 | public start() { 31 | console.log("Started!"); 32 | console.log(`Connected to emulator? ${this.isEmulator}`); 33 | } 34 | 35 | /** 36 | * Returns true when the function is running in the emulator. 37 | */ 38 | public get isEmulator() { 39 | return process.env.FUNCTIONS_EMULATOR === "true"; 40 | } 41 | 42 | /** 43 | * The Firestore instance we're connected to. 44 | */ 45 | public get db() { 46 | return this.firestore; 47 | } 48 | 49 | /** 50 | * The auth instance we're connected to. 51 | */ 52 | public get auth() { 53 | return this._auth; 54 | } 55 | 56 | /** 57 | * The storage instance that we're connected to.. 58 | */ 59 | public get storage() { 60 | return this.backingStorage; 61 | } 62 | } 63 | 64 | export const firebaseConnector = new ServerFirebaseConnector(); 65 | -------------------------------------------------------------------------------- /_firebase/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | 3 | service cloud.firestore { 4 | match /databases/{database}/documents { 5 | match /profiles/{profile} { 6 | allow read, create: if request.auth != null 7 | 8 | allow update: if request.auth.uid == request.resource.id 9 | } 10 | 11 | match /workspaces/{workspace} { 12 | allow read, update, delete: if request.auth != null 13 | && request.auth.uid in resource.data.collaborators 14 | 15 | allow create: if request.auth != null 16 | } 17 | 18 | match /candidates/{candidate} { 19 | // Allow read and update by the user that the candidate review is assigned 20 | // to or if the user is a collaborator on the workspace referenced by the 21 | // review. 22 | allow read, update: if request.auth != null 23 | && (request.auth.token.email == resource.data.email 24 | || request.auth.uid in get(/databases/$(database)/documents/workspaces/$(resource.data.workspaceUid)).data.collaborators) 25 | 26 | // Allow all access to the demo workspace for read. 27 | allow read: if resource.data.workspaceUid == 'DWJWmHIdofurrJtu' 28 | 29 | // Only allow delete if the user is referenced on the workspace document. 30 | allow delete: if request.auth != null 31 | && request.auth.uid in get(/databases/$(database)/documents/workspaces/$(resource.data.workspaceUid)).data.collaborators 32 | 33 | // Allow create for the workspace if the user is a collaborator on the 34 | // referenced workspace. 35 | allow create: if request.auth != null 36 | && request.auth.uid in get(/databases/$(database)/documents/workspaces/$(request.resource.data.workspaceUid)).data.collaborators 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /functions/src/generateAccount.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GenerateAccountRequest, 3 | GenerateAccountResponse, 4 | } from "./../../shared/messageModels"; 5 | import * as functions from "firebase-functions"; 6 | import {firebaseConnector} from "./ServerFirebaseConnector"; 7 | 8 | /** 9 | * Option to generate on the server side instead. 10 | */ 11 | export const generateAccount = functions 12 | .https.onCall({ 13 | cors: ["https://coderev.app"], 14 | minInstances: 0, 15 | maxInstances: 5, 16 | timeoutSeconds: 240, 17 | }, async ( 18 | request: functions.https.CallableRequest 19 | ) => { 20 | let response: GenerateAccountResponse = { 21 | succeeded: true, 22 | message: "Completed", 23 | }; 24 | 25 | if (!request.auth) { 26 | response = { 27 | succeeded: false, 28 | message: "No authentication info", 29 | }; 30 | 31 | return response; 32 | } 33 | 34 | const {username, password, label, workspaceUid, createdBy} = request.data; 35 | 36 | // TODO: Check if the user is in the workspace? 37 | 38 | console.info(`Generating user: ${username}`); 39 | 40 | const user = await firebaseConnector.auth.createUser({}); 41 | 42 | console.info(`Updating user: ${username}`); 43 | 44 | await firebaseConnector.auth.updateUser(user.uid, { 45 | displayName: label, 46 | email: username, 47 | password: password, 48 | }); 49 | 50 | console.info(`Setting claims on user: ${username}`); 51 | 52 | await firebaseConnector.auth.setCustomUserClaims(user.uid, { 53 | assigned_workspace_uid: workspaceUid, 54 | created_by_uid: createdBy.uid, 55 | }); 56 | 57 | // eslint-disable-next-line 58 | console.info(`Generated user: ${username} (requested by ${createdBy.name} (${createdBy.uid}))`); 59 | 60 | return response; 61 | }); 62 | -------------------------------------------------------------------------------- /web/components/LeftNavBottomButtons.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": [ 3 | { 4 | "source": "functions", 5 | "codebase": "default", 6 | "runtime": "nodejs20", 7 | "ignore": [ 8 | "node_modules", 9 | ".git", 10 | "firebase-debug.log", 11 | "firebase-debug.*.log" 12 | ], 13 | "predeploy": [ 14 | "npm --prefix \"$RESOURCE_DIR\" run lint", 15 | "npm --prefix \"$RESOURCE_DIR\" run build" 16 | ] 17 | } 18 | ], 19 | "firestore": { 20 | "rules": "_firebase/firestore.rules", 21 | "indexes": "_firebase/firestore.indexes.json" 22 | }, 23 | "hosting": { 24 | "public": "web/.output/public", 25 | "ignore": [ 26 | "firebase.json", 27 | "**/.*", 28 | "**/node_modules/**" 29 | ], 30 | "headers": [ 31 | { 32 | "source": "**/*.@(woff2|webp|jpg|png|mp4)", 33 | "headers": [ 34 | { 35 | "key": "Cache-Control", 36 | "value": "max-age=31556952" 37 | } 38 | ] 39 | }, 40 | { 41 | "source": "**/*.@(css|js)", 42 | "headers": [ 43 | { 44 | "key": "Cache-Control", 45 | "value": "max-age=604800" 46 | } 47 | ] 48 | }, 49 | { 50 | "source": "**/*.html", 51 | "headers": [ 52 | { 53 | "key": "Content-Security-Policy", 54 | "value": "frame-ancestors 'self'" 55 | } 56 | ] 57 | } 58 | ] 59 | }, 60 | "storage": { 61 | "rules": "_firebase/storage.rules" 62 | }, 63 | "emulators": { 64 | "auth": { 65 | "port": 9099 66 | }, 67 | "functions": { 68 | "port": 5001 69 | }, 70 | "firestore": { 71 | "port": 8080 72 | }, 73 | "hosting": { 74 | "port": 5080 75 | }, 76 | "pubsub": { 77 | "port": 8085 78 | }, 79 | "storage": { 80 | "port": 9199 81 | }, 82 | "ui": { 83 | "enabled": true, 84 | "port": 10001 85 | }, 86 | "singleProjectMode": true 87 | }, 88 | "remoteconfig": { 89 | "template": "_firebase/remoteconfig.template.json" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /web/utils/data/FirebaseSubscriptions.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { Unsubscribe } from 'firebase/firestore' 4 | 5 | /** 6 | * Plugin to manage global subscriptions 7 | */ 8 | class FirebaseSubscriptions { 9 | private _started: boolean = false 10 | private _subscriptions: Map = new Map< 11 | string, 12 | Unsubscribe 13 | >() 14 | 15 | constructor() { 16 | // Add event listener to unsubscribe the subscription when the user leaves. 17 | document.addEventListener('pagehide', this.dispose.bind(this)) 18 | document.addEventListener('cr_logout', this.dispose.bind(this)) 19 | } 20 | 21 | /** 22 | * Returns true if there is an active subscription for the key. 23 | * @param name The key name of the subscription. 24 | */ 25 | public hasSubscription(name: string) { 26 | return this._subscriptions.has(name) 27 | } 28 | 29 | /** 30 | * Registers a subscription so that it is automatically cleaned up on 31 | * unload or logout. However, can also be cleaned up explicitly. 32 | * @param name The name to register the subscription for manual unsub 33 | * @param subscription The unsub callback 34 | */ 35 | public register(name: string, subscription: Unsubscribe) { 36 | if (this._subscriptions.has(name)) { 37 | return 38 | } 39 | 40 | console.log(` 🔌 Registered subscription: "${name}"`) 41 | 42 | this._subscriptions.set(name, subscription) 43 | } 44 | 45 | /** 46 | * Unsubscribes the named subscription. 47 | * @param name The name the subscription was registred with. 48 | */ 49 | public unsubscribe(name: string) { 50 | if (!this._subscriptions.has(name)) { 51 | return 52 | } 53 | 54 | const unsubscribe = this._subscriptions.get(name) 55 | console.log(` 🧹 Cleaning up subscription: ${name}`) 56 | if (unsubscribe) { 57 | unsubscribe() 58 | } 59 | 60 | this._subscriptions.delete(name) 61 | } 62 | 63 | /** 64 | * Invoked on the document visibilitychange event which occurs on unload of the page. 65 | */ 66 | public dispose() { 67 | for (const [key] of this._subscriptions) { 68 | this.unsubscribe(key) 69 | } 70 | 71 | this._subscriptions.clear() 72 | } 73 | } 74 | 75 | export const firebaseSubscriptions = new FirebaseSubscriptions(); 76 | -------------------------------------------------------------------------------- /web/composables/useProfileOptions.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { computed } from "vue"; 3 | 4 | /** 5 | * Composable for wrapping the profile accepted options. 6 | */ 7 | export function useProfileOptions(profile: Ref) { 8 | /** 9 | * True when the user has explicitly opted-in to receive updates. 10 | */ 11 | const receiveEmails = computed({ 12 | get() { 13 | if (typeof profile.value.receiveEmails === "undefined") { 14 | return false; 15 | } 16 | 17 | return profile.value.receiveEmails.active; 18 | }, 19 | set(val: boolean) { 20 | profile.value.receiveEmails = { 21 | active: val, 22 | updatedUtc: dayjs.utc().toISOString(), 23 | }; 24 | }, 25 | }); 26 | 27 | /** 28 | * True when the user has explicitly opted-in to receive requests for feedback. 29 | */ 30 | const receiveFeedbackRequests = computed({ 31 | get() { 32 | if (typeof profile.value.receiveFeedbackRequests === "undefined") { 33 | return false; 34 | } 35 | 36 | return profile.value.receiveFeedbackRequests.active; 37 | }, 38 | set(val: boolean) { 39 | profile.value.receiveFeedbackRequests = { 40 | active: val, 41 | updatedUtc: dayjs.utc().toISOString(), 42 | }; 43 | }, 44 | }); 45 | 46 | /** 47 | * Performs the update of the values and then updates the profile in the backing store. 48 | */ 49 | async function updateNotificationOptions() { 50 | // Set the values from the computed values. If the user closes the 51 | // dialog even without saving, we treat that as as setting it to FALSE. 52 | profile.value.receiveEmails = { 53 | active: receiveEmails.value, 54 | updatedUtc: dayjs.utc().toISOString(), 55 | }; 56 | 57 | profile.value.receiveFeedbackRequests = { 58 | active: receiveFeedbackRequests.value, 59 | updatedUtc: dayjs.utc().toISOString(), 60 | }; 61 | 62 | await profileRepository.updateFields(profile.value.uid, { 63 | receiveEmails: { 64 | active: receiveEmails.value, 65 | updatedUtc: dayjs.utc().toISOString(), 66 | }, 67 | receiveFeedbackRequests: { 68 | active: receiveFeedbackRequests.value, 69 | updatedUtc: dayjs.utc().toISOString(), 70 | } 71 | }); 72 | } 73 | 74 | return { 75 | receiveEmails, 76 | receiveFeedbackRequests, 77 | updateNotificationOptions, 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /shared/viewModels.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file holds view models which are only used on the front-end. 3 | */ 4 | 5 | import { ReviewComment } from "./domainModels" 6 | import { MediaRef } from "./models" 7 | 8 | /** 9 | * Type used to represent user uploaded or created file. 10 | */ 11 | export type SourceFile = { 12 | /** 13 | * The extension with the leading . 14 | */ 15 | ext: string 16 | 17 | /** 18 | * The full name of the file including the extension. 19 | */ 20 | name: string 21 | 22 | /** 23 | * The text contents of the file. 24 | */ 25 | text: string, 26 | 27 | /** 28 | * The last hash for this file; check against this to see if there's a change 29 | * in the contents. 30 | */ 31 | hash?: string 32 | 33 | /** 34 | * When present, this means that the file has a backing reference stored in 35 | * Cloud Storage. If this value is undefined, then the file hasn't been saved 36 | * yet. 37 | */ 38 | ref?: MediaRef 39 | 40 | /** 41 | * The number of top-level comments for this file. 42 | */ 43 | commentCount?: number 44 | } 45 | 46 | /** 47 | * Represents a line selection in a source file. 48 | */ 49 | export type SourceSelection = { 50 | /** 51 | * The UID of the source reference. 52 | */ 53 | sourceUid: string; 54 | 55 | /** 56 | * The name of the source reference. 57 | */ 58 | sourceName: string; 59 | 60 | /** 61 | * The character index of the start of the selection. 62 | */ 63 | from: number; 64 | 65 | /** 66 | * The character index of the end of the selection. 67 | */ 68 | to: number; 69 | 70 | /** 71 | * The line index of the start of the selection. 72 | */ 73 | fromLine: number; 74 | 75 | /** 76 | * The line index of the en of the selection. 77 | */ 78 | toLine: number; 79 | } 80 | 81 | /** 82 | * Corresponds to the Quasar menu positions. 83 | */ 84 | export type MenuPosition = 85 | | "top left" 86 | | "top middle" 87 | | "top right" 88 | | "top start" 89 | | "top end" 90 | | "center left" 91 | | "center middle" 92 | | "center right" 93 | | "center start" 94 | | "center end" 95 | | "bottom left" 96 | | "bottom middle" 97 | | "bottom right" 98 | | "bottom start" 99 | | "bottom end" 100 | | undefined; 101 | 102 | /** 103 | * Models a simple comment chain with one root comment. 104 | */ 105 | export type CommentChain = { 106 | /** 107 | * The root comment of the chain. 108 | */ 109 | rootComment: ReviewComment 110 | 111 | /** 112 | * An array of reply comments. 113 | */ 114 | replyComments: ReviewComment[] 115 | } 116 | -------------------------------------------------------------------------------- /web/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | devtools: { enabled: true }, 4 | site: { 5 | url: "coderev.app" 6 | }, 7 | compatibilityDate: '2024-11-01', 8 | app: { 9 | pageTransition: { name: "page", mode: "out-in" } 10 | }, 11 | modules: [ 12 | "nuxt-quasar-ui", 13 | "@pinia/nuxt", 14 | "dayjs-nuxt", 15 | "@vueuse/nuxt", 16 | "@nuxtjs/google-fonts", 17 | '@nuxtjs/robots', 18 | '@nuxtjs/sitemap', 19 | '@nuxt/content', 20 | ], 21 | sitemap: { 22 | include: ['/', '/blog', '/blog/**'] 23 | }, 24 | robots: { 25 | disallow: ['/login', '/home', '/privacy', '/terms'], 26 | }, 27 | imports: { 28 | dirs: ["../shared/**", "./stores/**", "./utils/**"], 29 | global: true 30 | }, 31 | googleFonts: { 32 | families: { 33 | "M PLUS 2": [300, 400, 500, 700] 34 | } 35 | }, 36 | quasar: { 37 | // https://nuxt.com/modules/quasar 38 | sassVariables: "~/quasar-variables.sass", 39 | extras: { 40 | animations: ["fadeInUp", "fadeInDown"], 41 | }, 42 | plugins: [ 43 | "Notify" 44 | ] 45 | }, 46 | pinia: { 47 | // https://pinia.vuejs.org/ssr/nuxt.html 48 | }, 49 | dayjs: { 50 | locales: ["en"], 51 | plugins: [ 52 | "relativeTime", 53 | "utc", 54 | "timezone", 55 | "duration", 56 | "isTomorrow", 57 | "isToday", 58 | "isYesterday", 59 | "isSameOrAfter", 60 | "isSameOrBefore", 61 | ], 62 | defaultLocale: "en", 63 | defaultTimezone: "America/New_York", 64 | }, 65 | content: { 66 | build: { 67 | markdown: { 68 | highlight: { 69 | theme: { 70 | // Default theme (same as single string) 71 | default: 'github-light', 72 | // Theme used if `html.dark` 73 | dark: 'github-dark', 74 | // Theme used if `html.sepia` 75 | sepia: 'monokai' 76 | }, 77 | langs: ['json', 'js', 'ts', 'html', 'css', 'vue', 'shell', 'csharp'] 78 | } 79 | } 80 | } 81 | }, 82 | nitro: { 83 | prerender: { 84 | routes: ['/sitemap.xml', '/robots.txt'] 85 | } 86 | }, 87 | routeRules: { 88 | // https://nuxt.com/docs/guide/concepts/rendering#hybrid-rendering 89 | "/": { prerender: true }, 90 | "/blog": { prerender: true }, 91 | "/blog/**": { prerender: true }, 92 | "/terms": { prerender: true }, 93 | "/privacy": { prerender: true }, 94 | "/login": { ssr: false }, 95 | "/home": { ssr: false }, 96 | "/workspace/**": { ssr: false }, 97 | "/review/**": { ssr: false }, 98 | }, 99 | }); 100 | -------------------------------------------------------------------------------- /web/components/NewWorkspaceDialog.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 105 | 106 | 111 | -------------------------------------------------------------------------------- /web/pages/blog.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 102 | 103 | 113 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coderev", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare", 11 | "emulators": "firebase emulators:start --only auth,firestore,functions,hosting,storage --import ../.data/firebase --export-on-exit" 12 | }, 13 | "devDependencies": { 14 | "@nuxtjs/google-fonts": "^3.2.0", 15 | "@shikijs/langs": "^2.3.2", 16 | "@types/markdown-it-highlightjs": "^3.3.4", 17 | "@types/nanoid-dictionary": "^4.2.0", 18 | "@types/node": "^18.16.19", 19 | "@types/sanitize-html": "^2.11.0", 20 | "nuxt": "^3.15.4", 21 | "nuxt-quasar-ui": "^2.1.7", 22 | "quasar-extras-svg-icons": "^1.36.0", 23 | "sass": "^1.71.0", 24 | "sass-loader": "^14.1.1" 25 | }, 26 | "dependencies": { 27 | "@codemirror/lang-cpp": "^6.0.2", 28 | "@codemirror/lang-css": "^6.2.0", 29 | "@codemirror/lang-html": "^6.4.5", 30 | "@codemirror/lang-java": "^6.0.1", 31 | "@codemirror/lang-javascript": "^6.1.9", 32 | "@codemirror/lang-json": "^6.0.1", 33 | "@codemirror/lang-markdown": "^6.2.0", 34 | "@codemirror/lang-php": "^6.0.1", 35 | "@codemirror/lang-python": "^6.1.3", 36 | "@codemirror/lang-rust": "^6.0.1", 37 | "@codemirror/lang-sass": "^6.0.2", 38 | "@codemirror/lang-sql": "^6.5.2", 39 | "@codemirror/lang-vue": "^0.1.2", 40 | "@codemirror/lang-xml": "^6.0.2", 41 | "@codemirror/legacy-modes": "^6.3.3", 42 | "@firebase/app-types": "^0.9.0", 43 | "@nuxt/content": "^3.1.0", 44 | "@nuxtjs/robots": "^5.2.2", 45 | "@nuxtjs/sitemap": "^7.2.4", 46 | "@pinia/nuxt": "^0.5.1", 47 | "@quasar/extras": "^1.16.9", 48 | "@replit/codemirror-lang-csharp": "^6.1.0", 49 | "@tiptap/extension-character-count": "^2.0.4", 50 | "@tiptap/extension-link": "^2.0.4", 51 | "@tiptap/pm": "^2.0.4", 52 | "@tiptap/starter-kit": "^2.0.4", 53 | "@tiptap/vue-3": "^2.0.4", 54 | "@vueuse/integrations": "^12.5.0", 55 | "@vueuse/nuxt": "^10.2.1", 56 | "clsx": "^2.1.1", 57 | "codemirror": "^6.0.1", 58 | "dayjs": "^1.11.9", 59 | "dayjs-nuxt": "^1.1.2", 60 | "firebase": "11.0.1", 61 | "firebase-admin": "12.7.0", 62 | "firebase-frameworks": "0.11.5", 63 | "firebase-functions": "6.1.0", 64 | "lexorank": "^1.0.5", 65 | "lru-cache": "^10.0.0", 66 | "markdown-it": "^14.1.0", 67 | "markdown-it-highlightjs": "^4.2.0", 68 | "nanoid": "^4.0.2", 69 | "nanoid-dictionary": "^4.3.0", 70 | "prosemirror-commands": "^1.5.2", 71 | "prosemirror-dropcursor": "^1.8.1", 72 | "prosemirror-gapcursor": "^1.3.2", 73 | "prosemirror-history": "^1.3.2", 74 | "prosemirror-keymap": "^1.2.2", 75 | "prosemirror-model": "^1.19.3", 76 | "prosemirror-schema-list": "^1.3.0", 77 | "prosemirror-state": "^1.4.3", 78 | "prosemirror-transform": "^1.7.3", 79 | "prosemirror-view": "^1.31.6", 80 | "qrcode": "^1.5.3", 81 | "quasar": "^2.17.4", 82 | "sanitize-html": "^2.11.0", 83 | "sitemap": "^8.0.0", 84 | "tailwind-merge": "^2.5.5", 85 | "tailwindcss": "^3.4.16", 86 | "thememirror": "^2.0.1", 87 | "vue": "latest", 88 | "vue-codemirror": "^6.1.1", 89 | "vue-markdown-render": "^2.0.1", 90 | "vue-router": "latest", 91 | "vue3-smooth-dnd": "^0.0.5" 92 | }, 93 | "packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447" 94 | } 95 | -------------------------------------------------------------------------------- /web/pages/workspace/[uid]/c/[candidateUid].vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 113 | 114 | 119 | -------------------------------------------------------------------------------- /web/components/workspace/WorkspaceFileSelectorDialog.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /web/pages/blog/[slug].vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 49 | 50 | 159 | -------------------------------------------------------------------------------- /web/components/PreferencesDialog.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /web/layouts/app-layout.vue: -------------------------------------------------------------------------------- 1 | 100 | 101 | 138 | 139 | 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodeRev 2 | 3 | [Try CodeRev.app for your interviews -- it's free!](https://coderev.app) 4 | 5 | https://github.com/user-attachments/assets/fe3cb39b-ba8c-4539-9b5c-cf949794724b 6 | 7 | CodeRev is a lightweight tool to help you organize and conduct technical interviews using code reviews rather than leetcode. 8 | 9 | ## Rationale 10 | 11 | In the age of StackOverflow and ChatGPT, is leetcode really the best way to evaluate technical candidates? 12 | 13 | *Was it ever?* 14 | 15 | Code review as interview has many benefits for any engineering team: 16 | 17 | * Understand how candidates interact with isolated parts of your codebase. 18 | * Better reflects day-to-day engineering responsibilities in your team. 19 | * Realistic representation of how a candidate thinks and communicates. 20 | * Open-ended and collaborative; no black and white responses. 21 | 22 | ## How it works 23 | 24 | 1. Create a workspace for each of the roles you're screening for. 25 | 2. Upload and edit your source files that you'd like the candidates to review. 26 | 3. Add candidates; each gets a separate view of the source to work on. 27 | 4. Candidates review the code in their workspace and provide comments and feedback. 28 | 29 | ## Benefits: 30 | 31 | Why use CodeRev? Why not just a GitHub repo? 32 | 33 | 1. **Easy setup**: Lightweight, focused, and simple; just a few clicks to get started. 34 | 2. **Isolated**: No exposure of your internal GitHub repos, accounts, and workspaces. 35 | 3. **Collaborative**: Review candidate responses with your team and leave your own notes. (Coming soon) 36 | 4. **Easy to compare**: See feedback from different candidates to the same code to compare. (Coming soon) 37 | 5. **Define timed access**: Automatically release the workspace to your candidate and optionally revoke it. (Coming soon) 38 | 39 | ## FAQ: 40 | 41 | * **Why did you make this tool?** I went through an interview where the process involved reviewing a snippet of code and really enjoyed the experience. I thought it would be great if there was a dedicated tool for this. 42 | * **What's the stack** Nuxt3 (Vue.js) + Quasar Framework + Google Cloud Firebase. Productive, fast, and more or less free. 43 | 44 | ## Development 45 | 46 | Development can be done locally using the Firebase CLI emulators. 47 | 48 | 1. Install the Firebase CLI tooling for your platform: https://firebase.google.com/docs/cli 49 | 2. Make a copy of `web/env.template` as `web/.env` and add your Firebase config. 50 | 3. Start the backend 51 | 4. Start the frontend 52 | 53 | ``` 54 | # Start the emulators in on console 55 | cd web 56 | yarn # Restore 57 | yarn dev 58 | 59 | # Start the backend 60 | firebase emulators:start --only auth,firestore,functions,hosting,storage \ 61 | --import .data/firebase --export-on-exit 62 | ``` 63 | 64 | ## Deploying 65 | 66 | You'll need a Firebase project to deploy: 67 | 68 | ``` 69 | # From web: 70 | yarn generate # This will generate the static routes. 71 | 72 | # From the root: 73 | firebase deploy 74 | 75 | # Deploy only the hosting (making a front-end change): 76 | firebase deploy --only hosting 77 | ``` 78 | 79 | There is also a GitHub Action which manages automatic deployment of all assets. 80 | 81 | ## Using Functions Framework 82 | 83 | Functions framework allows the SSR backend to run on the server. However, this is currently not needed for CodeRev. 84 | 85 | To enable, swap the `hosting` configuration: 86 | 87 | ```json 88 | "source": "web", 89 | "ignore": [ 90 | "firebase.json", 91 | "**/.*", 92 | "**/node_modules/**" 93 | ], 94 | "frameworksBackend": { 95 | "region": "us-central1" 96 | } 97 | ``` 98 | 99 | ## Setting up CORS for Storage 100 | 101 | Storage requires that you set up CORS when using a custom domain. 102 | 103 | Create the bucket `source.coderev.app` and then follow this guide: https://cloud.google.com/storage/docs/using-cors 104 | 105 | Instead of doing it from the command line, you can use the Cloud Shell in console and open an editor in browser. 106 | 107 | Create a file `cors.json` 108 | 109 | ```json 110 | [ 111 | { 112 | "origin": ["https://coderev.app"], 113 | "method": ["GET"], 114 | "responseHeader":[ 115 | "Access-Control-Allow-Origin" 116 | ], 117 | "maxAgeSeconds": 3600 118 | } 119 | ] 120 | ``` 121 | 122 | From the console, run: 123 | 124 | ``` 125 | gcloud storage buckets update gs://source.coderev.app --cors-file=cors.json 126 | ``` 127 | -------------------------------------------------------------------------------- /web/components/SideDialogShell.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 158 | 159 | 164 | -------------------------------------------------------------------------------- /web/components/workspace/WorkspaceTeamDialog.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 159 | 160 | 167 | -------------------------------------------------------------------------------- /web/content/7-strategies-for-using-code-reviews-in-technical-interviews.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-02-03 3 | author: Charles Chen 4 | description: Ready to incorporate code reviews into your candidate selection process but not quite sure how to start? Here are 7 strategies to consider; you can even mix and match! 5 | --- 6 | 7 | # 7 Strategies for Using Code Reviews in Technical Interviews 8 | 9 | [{{ $doc.author }}]{.post-author } - [{{ $doc.date }}]{.post-date} 10 | 11 | [{{ $doc.description }}]{.post-desc} 12 | 13 | When using code reviews as an interview tool, think of it like creating a sandbox or playground for the interviewer and candidate to explore. 14 | 15 | Like burying treasures in the sand, the objective is to create multiple points of interest which can allow a candidate to express both their breadth and depth as you explore together. Unlike leetcode based interviews which can feel more binary in outcome, a well-designed code review can allow for better stratification of candidates by providing a greater range of responses. 16 | 17 | Try to keep it interesting by mixing and matching these 7 different strategies in your code review! 18 | 19 | --- 20 | 21 | ## 1. "Au naturel" 22 | 23 | Take actual, relevant and interesting parts of an active codebase as-is and use those as the context for review. Data access, exception handling, input handling - these all make for great points of focus to see a candidate’s feedback on an existing codebase and how quickly they can read and understand your existing code. 24 | 25 | ## 2. Bug hunter 26 | 27 | Intentionally introduce some logical flaw or defect and see if a candidate can spot it. A good idea is to go back and find recent bugs that were solved and pull the source before the fix was applied. Can the candidate identify the root cause? How would the candidate suggest resolving the defect? How does that response differ from the one that was implemented? 28 | 29 | ## 3. Refactor and redesign 30 | 31 | Recently completed a refactor or planning a refactor? Use the code prior to the refactor as the context and see how the candidate thinks about the code before the refactor and what strategies a candidate would use to plan and execute the refactor. See if the candidate can identify why a refactor would be desired and evaluate the sophistication of their approach; you might be surprised and find an entirely novel alternate approach! 32 | 33 | This is particularly useful when a candidate is joining a brownfield project. 34 | 35 | ## 4. Performance minded 36 | 37 | Find code that was recently fixed for a performance issue and see if the candidate can spot why a piece of code might be slow. See if the candidate can propose an algorithm, alternate design, or fix to improve the performance of the code. 38 | 39 | Include existing SQL DDL schema and common natural language queries that the application will perform. Remove the index definitions and see if the candidate can propose indices or alternate designs to improve performance. 40 | 41 | Instead of asking about the principles of Big-O notation, see if the candidate can actually spot some `O(n^2)` code or `N+1` issues in data access code! 42 | 43 | ## 5. Test focused 44 | 45 | Share a fragment of code and a set of unit tests for the code. Are all the cases covered? Are there cases not covered? How could the unit tests be improved? This perspective may be more important in the coming age of AI generated code: understanding the domain space and use case and how to write high coverage unit tests - or evaluating the completeness of generated unit tests - becomes a key skill. 46 | 47 | ## 6. Security hawk 48 | 49 | Use code that has subtle security flaws and see if the candidate can identify said flaws. Rather than merely asking what an XSS or SQL injection attack is, see if the candidate can identify such flaws in code by using code that lacks protection against said attacks. Again, as teams come to rely on AI-generated code, having the experience to identify potential security flaws in the generated code becomes more important. 50 | 51 | ## 7. Best practices 52 | 53 | For more senior positions, focusing on best practices is a great way to find candidates that can identify, communicate, and teach best practices to more junior candidates and direct reports. 54 | 55 | Introduce some code with obvious bad practices and see if the candidate can identify the bad practices and explain why they're bad practices. 56 | 57 | --- 58 | 59 | These strategies are just a start to help you think about ways to utilize code reviews. Use these as starting points to formulate your own strategies that fit the objectives of your team. 60 | 61 | Trying to filter for staff and principal level engineers? Try incorporating all 7 strategies and see if the candidate has both the breadth and depth to match the leveling for the role. 62 | 63 | Code reviews are intended to be open-ended and facilitate communication and collaboration so think of it like creating a sandbox to explore together! 64 | -------------------------------------------------------------------------------- /shared/domainModels.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Domain specific model types built off of the base model types. 3 | */ 4 | 5 | import { Archivable, EmbeddedRef, Entity, MediaRef } from "./models"; 6 | 7 | /** 8 | * Defines the role types for the users on the workspace. 9 | */ 10 | export type CollaboratorRole = "owner" | "editor" | "reviewer"; 11 | 12 | /** 13 | * This type is used to model invites which include a pending property to indicate 14 | * that the invitation has not yet been accepted. 15 | */ 16 | export type CollaboratorRef = EmbeddedRef & { 17 | /** 18 | * When this is true, the collaborator has only been invited and has not yet 19 | * joined the trip. 20 | */ 21 | pending: boolean; 22 | 23 | /** 24 | * The role for the collaborator 25 | */ 26 | role: CollaboratorRole; 27 | }; 28 | 29 | /** 30 | * An embedded type on the workspace that represents ratings and notes for a given 31 | * candidate. 32 | */ 33 | export type Rating = { 34 | /** 35 | * A numeric score of the candidate's submission 36 | */ 37 | overall: number, 38 | /** 39 | * How deep the candidate's submissions were 40 | */ 41 | depth?: number, 42 | /** 43 | * How clearly the candidate communicated 44 | */ 45 | clarity?: number, 46 | /** 47 | * How thorough the candidate was overall 48 | */ 49 | thoroughness?: number, 50 | /** 51 | * Comments entered by a user creating the rating. 52 | */ 53 | comments: string, 54 | /** 55 | * The author of the rating including the `addedUtc` 56 | */ 57 | author: EmbeddedRef 58 | } 59 | 60 | /** 61 | * Defines a workspace which contains a set of notes. 62 | */ 63 | export type Workspace = { 64 | /** 65 | * A description for the workspace 66 | */ 67 | description?: string; 68 | 69 | /** 70 | * The collaborators who have created accounts to work on this trip. The refs 71 | * point to user profiles. The key is the UID. This will always contain 72 | * the user who created the trip. 73 | */ 74 | collaborators: Record; 75 | 76 | /** 77 | * Represents a set of content which every workspace is associated with. A content 78 | * set can be associated with multiple workspaces. For example, a set of documents 79 | * can be shared by multiple teams in different workspaces. 80 | */ 81 | sources: Record; 82 | 83 | /** 84 | * A record which holds the ratings for a given candidate. The key is the 85 | * candidate UID and there can be an array of ratings. 86 | */ 87 | ratings?: Record; 88 | } & Entity & Archivable; 89 | 90 | /** 91 | * The context type of a comment so we know what the comment is attached to. 92 | */ 93 | export type ContextType = 94 | | "source" 95 | | "comment" 96 | 97 | /** 98 | * Defines a comment 99 | */ 100 | export type ReviewComment = { 101 | /** 102 | * A UID for this comment used for updates and deletion. 103 | */ 104 | uid: string; 105 | 106 | /** 107 | * The body of the comment. 108 | */ 109 | text: string 110 | 111 | /** 112 | * Represents a range in the source that the comment is referencing. 113 | */ 114 | sourceRange?: number[] 115 | 116 | /** 117 | * Determines the type of context for this comment. 118 | */ 119 | contextType: ContextType 120 | 121 | /** 122 | * The UID of the context which references either a source file or another comment. 123 | */ 124 | contextUid: string 125 | 126 | /** 127 | * The reference to the author of the comment and also includes the timestamp. 128 | */ 129 | author: EmbeddedRef 130 | } 131 | 132 | /** 133 | * This document represents a candidate review. When we create it, we copy over 134 | * the media refs from the workspace. 135 | */ 136 | export type CandidateReview = { 137 | /** 138 | * The UID of the workspace that is referenced by this review. 139 | */ 140 | workspaceUid: string; 141 | 142 | /** 143 | * Copy this value so we don't need to have permissions to read the workspace. 144 | */ 145 | workspaceName: string; 146 | 147 | /** 148 | * The email address of the candidate that this review is assigned to. 149 | */ 150 | email: string; 151 | 152 | /** 153 | * If the user selects to create an anonymous account, then we want to allow 154 | * entry of a label here. 155 | */ 156 | label?: string; 157 | 158 | /** 159 | * Represents a set of content which every workspace is associated with. A content 160 | * set can be associated with multiple workspaces. For example, a set of documents 161 | * can be shared by multiple teams in different workspaces. 162 | */ 163 | sources: Record; 164 | 165 | /** 166 | * The comments attached to this review. The key is a unique ID for the 167 | * comment. 168 | */ 169 | comments: Record; 170 | } & Entity & Archivable; 171 | -------------------------------------------------------------------------------- /web/components/workspace/WorkspaceFileItem.vue: -------------------------------------------------------------------------------- 1 | 107 | 108 | 182 | -------------------------------------------------------------------------------- /web/pages/login.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 181 | 182 | 192 | -------------------------------------------------------------------------------- /web/utils/data/Storage.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { type FirebaseStorage, uploadBytes, ref, getDownloadURL, deleteObject, getBlob } from "firebase/storage"; 3 | 4 | /** 5 | * Models the result of a file upload 6 | */ 7 | export type UploadResult = { 8 | uid: string, 9 | size: number, 10 | url: string, 11 | path: string, 12 | addedAtUtc: string, 13 | } 14 | 15 | /** 16 | * Storage wrapper. 17 | */ 18 | export abstract class Storage { 19 | /** 20 | * Base constructor 21 | */ 22 | constructor() {} 23 | 24 | /** 25 | * Inheriting classes override this class to provide the storage root. 26 | */ 27 | protected abstract get storage(): FirebaseStorage; 28 | 29 | /** 30 | * Adds a source file to the backing store. Unless an explicit ID is supplied, 31 | * a hash of the file contents is calculated and assigned as the reference ID. 32 | * This way, if the same file is added twice, only one copy is stored since the 33 | * path will already exist based on the hash of the contents. 34 | * @param path The path part for the image. 35 | * @param originalFileName The original file name. 36 | * @param file The blob that contains the contents of the media. 37 | * @param size The size of the image for compression. 38 | * @param additionalMetadata Additional metadata to apply to the file. 39 | * @param explicitUid When supplied, the explicit UID to use for the file. 40 | * @returns A Media object which needs to be persisted. The ID of which is the SHA-1 hash of the contents. 41 | */ 42 | protected async addFile( 43 | path: string, 44 | originalFileName: string, 45 | file: File, 46 | additionalMetadata?: Record, 47 | explicitUid?: string 48 | ) : Promise { 49 | // The extension includes the . 50 | let extension = originalFileName 51 | .substring(originalFileName.lastIndexOf('.')) 52 | .toLowerCase() 53 | 54 | let uid = explicitUid 55 | 56 | if (!uid) { 57 | // Calculate the hash of the file to use as an ID and path if no ID is supplied. 58 | const buffer = await file.arrayBuffer() 59 | const hashBuffer = await crypto.subtle.digest('SHA-1', buffer) 60 | const hashArray = Array.from(new Uint8Array(hashBuffer)) 61 | const hashHex = hashArray 62 | .map((b) => b.toString(16).padStart(2, '0')) 63 | .join('') 64 | .toLowerCase() 65 | 66 | uid = hashHex 67 | } else { 68 | if (!additionalMetadata) { 69 | additionalMetadata = {} 70 | } 71 | 72 | // Track that we've assigned an explicit UID. 73 | additionalMetadata["explicitUid"] = uid 74 | } 75 | 76 | // Store the file. 77 | const fileRef = ref(this.storage, `${path}/${uid}${extension}`) 78 | const result = await uploadBytes(fileRef, file, { 79 | // This sets the caching policy on the image in storage. 80 | cacheControl: 'public, max-age=3600, s-maxage=3600', 81 | customMetadata: { 82 | originalFileName, 83 | ...additionalMetadata, 84 | }, 85 | }) 86 | 87 | const downloadUrl = await getDownloadURL(fileRef) 88 | 89 | return { 90 | uid: uid, 91 | size: result.metadata.size, 92 | url: downloadUrl, 93 | path: result.ref.fullPath, 94 | addedAtUtc: dayjs().utc().toISOString(), 95 | } 96 | } 97 | 98 | /** 99 | * Deletes a file from the backing storage. 100 | * @param path The path to the file to delete. 101 | */ 102 | public async deleteFile(path: string) : Promise { 103 | const targetRef = ref(this.storage, path) 104 | 105 | try { 106 | console.log(`🗑️ Deleting files at path: ${targetRef.fullPath}`) 107 | await deleteObject(targetRef) 108 | return true 109 | } catch (e) { 110 | console.error(`Deletion of file: ${path} failed.`) 111 | console.error(e) 112 | return false 113 | } 114 | } 115 | 116 | /** 117 | * Reads the text contents of a given file in the storage target. 118 | * @param path The path to the file in this storage target. 119 | * @returns The string contents of the file. 120 | */ 121 | public async readText(path: string) { 122 | const targetRef = ref(this.storage, path) 123 | 124 | try { 125 | const blob = await getBlob(targetRef) 126 | const text = await blob.text() 127 | return text 128 | } catch (e) { 129 | console.error(`Read of file: ${path} failed.`) 130 | console.error(e) 131 | return "" 132 | } 133 | } 134 | } 135 | 136 | /** 137 | * This is the storage connector for the source files. 138 | */ 139 | export class SourceStorage extends Storage { 140 | /** 141 | * Points to the storage bucket for the source files. 142 | */ 143 | protected get storage(): FirebaseStorage { 144 | return firebaseConnector.sourceStorage; 145 | } 146 | 147 | /** 148 | * Adds a set of files to the workspace. Supply an explicit UID to overwrite an 149 | * existing file with a different set of contents. 150 | * @param workspaceUid The UID of the workspace to add the files for. 151 | * @param files The file to add to the workspace folder. 152 | * @param metadata The set of metadata to assign to the file. 153 | * @param explicitUid When supplied, the explicit UID to use for the file. 154 | */ 155 | public async addSourceFile( 156 | workspaceUid: string, 157 | file: File, 158 | metadata?: Record, 159 | explicitUid?: string 160 | ) : Promise { 161 | return await super.addFile( 162 | workspaceUid, 163 | file.name, 164 | file, 165 | metadata, 166 | explicitUid 167 | ) 168 | } 169 | } 170 | 171 | export const sourceStorage = new SourceStorage(); 172 | -------------------------------------------------------------------------------- /web/stores/appStore.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "firebase/auth"; 2 | import { where } from "firebase/firestore"; 3 | import { Dark } from "quasar"; 4 | 5 | export const defaultProfile: Profile = { 6 | uid: "default", 7 | name: "default", 8 | email: "default@coderev.app", 9 | createdAtUtc: "", 10 | createdBy: { 11 | uid: "default", 12 | name: "default", 13 | entityType: "user", 14 | addedUtc: "default", 15 | }, 16 | }; 17 | 18 | /** 19 | * Store for application level state. 20 | */ 21 | export const useAppStore = defineStore("appStore", () => { 22 | const dayjs = useDayjs(); 23 | 24 | const dark = computed(() => Dark.isActive); 25 | 26 | const showLeftDrawer = ref(true) 27 | 28 | const showPreferencesDialog = ref(false) 29 | 30 | const currentUser = ref(); 31 | 32 | const profile = ref({ ...defaultProfile }); 33 | 34 | const user = computed(() => currentUser.value) 35 | 36 | let profileSubscriptionStarted = false; 37 | 38 | /** 39 | * Toggles dark mode. 40 | */ 41 | function toggleDarkMode() { 42 | Dark.toggle(); 43 | } 44 | 45 | /** 46 | * Clears the user from local storage and resets the application state. 47 | */ 48 | function clearUser() { 49 | // Clear the user and token expiration time 50 | currentUser.value = undefined; 51 | profile.value = { ...defaultProfile }; 52 | profileSubscriptionStarted = false; 53 | } 54 | 55 | /** 56 | * Gets an embedded ref that represents the current user. 57 | */ 58 | function getCurrentUserRef(): EmbeddedRef { 59 | return { 60 | uid: profile.value?.uid ?? "", 61 | name: profile.value?.name ?? "", 62 | addedUtc: dayjs().utc().toISOString(), 63 | entityType: "profile", 64 | }; 65 | } 66 | 67 | /** 68 | * Convenience method that converts a profile to a CollaboratorRef 69 | * @param profile The profile to convert to an invite ref. 70 | */ 71 | function profileToCollaboratorRef( 72 | profile: Profile, 73 | role?: CollaboratorRole 74 | ): CollaboratorRef { 75 | return { 76 | uid: profile.uid, 77 | name: profile.name, 78 | addedUtc: dayjs().utc().toISOString(), 79 | entityType: "user", 80 | pending: !profile.activatedUtc ? true : false, 81 | role: role ?? "editor", 82 | }; 83 | } 84 | 85 | /** 86 | * Convenience method to convert the current profile to a collaborator ref. 87 | */ 88 | function getCurrentCollaboratorRef() { 89 | return profileToCollaboratorRef(profile.value, "owner"); 90 | } 91 | 92 | /** 93 | * Sets the user and starts a profile subscription for updates to the user's profile. 94 | * @param authUser The authenticated user. 95 | */ 96 | async function setUser(authUser: User) { 97 | console.log(` ⚡︎ Setting user: ${authUser.email}`) 98 | 99 | currentUser.value = authUser; 100 | 101 | // Get the profile by the email of the authenticated user. 102 | try { 103 | if (!profileSubscriptionStarted) { 104 | let userProfile: Profile = 105 | ( 106 | await profileRepository.findByFilter( 107 | where("uid", "==", authUser.uid) 108 | ) 109 | )?.[0] ?? undefined; 110 | 111 | if (!userProfile) { 112 | const name = authUser.displayName ?? authUser.email ?? `User ${authUser.uid}` 113 | 114 | console.log(` ⮑ Creating profile for user: ${authUser.email}`) 115 | 116 | userProfile = { 117 | uid: authUser.uid, 118 | name: name, 119 | email: authUser.email ?? `user.${authUser.uid}@coderev.app`, 120 | createdAtUtc: dayjs().utc().toISOString(), 121 | createdBy: { 122 | uid: authUser.uid, 123 | name: name, 124 | addedUtc: dayjs().utc().toISOString(), 125 | entityType: "user" 126 | } 127 | } 128 | 129 | // Add the profile to the backend 130 | await profileRepository.create(userProfile); 131 | } else { 132 | console.log(` ⮑ Retrieved profile for user: ${authUser.uid}`) 133 | } 134 | 135 | profile.value = userProfile; 136 | 137 | startSubscription(); 138 | } 139 | } catch (error) { 140 | console.error("Error retrieving profile"); 141 | console.log(error); 142 | clearUser(); 143 | 144 | if (!window.location.href.includes("login")) { 145 | window.location.replace("/login"); 146 | } 147 | } 148 | } 149 | 150 | /** 151 | * This subscription listens for server side updates of the the 152 | * profile. 153 | */ 154 | function startSubscription() { 155 | if (!profile.value || profileSubscriptionStarted) { 156 | return; 157 | } 158 | 159 | console.log(` 📡 Subscribing profile for UID: ${profile.value.uid}`); 160 | 161 | const profileSubscription = profileRepository.subscribe( 162 | { 163 | added: (newProfile) => { 164 | // This shouldn't happen. 165 | profile.value = newProfile; 166 | }, 167 | modified: (modifiedProfile) => { 168 | profile.value = modifiedProfile; 169 | }, 170 | removed: (deletedProfile) => { 171 | clearUser(); 172 | window.location.replace("/login"); 173 | }, 174 | }, 175 | where("uid", "==", profile.value.uid) 176 | ); 177 | 178 | firebaseSubscriptions.register( 179 | `profile.${profile.value.uid}`, 180 | profileSubscription 181 | ); 182 | 183 | profileSubscriptionStarted = true; 184 | } 185 | 186 | return { 187 | dark, 188 | showLeftDrawer, 189 | showPreferencesDialog, 190 | user, 191 | profile, 192 | toggleDarkMode, 193 | getCurrentUserRef, 194 | getCurrentCollaboratorRef, 195 | setUser, 196 | clearUser, 197 | }; 198 | }); 199 | -------------------------------------------------------------------------------- /web/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 162 | 163 | 214 | 215 | 220 | -------------------------------------------------------------------------------- /shared/models.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base model types 3 | */ 4 | 5 | /** 6 | * A minimal reference that references an entity with a date; 7 | */ 8 | export type MinimalRef = { 9 | /** 10 | * The UID of the entity being referenced. 11 | */ 12 | uid: string; 13 | /** 14 | * UTC date and time that the reference was added. 15 | */ 16 | addedUtc: string; 17 | }; 18 | 19 | /** 20 | * An embedded reference. 21 | */ 22 | export type EmbeddedRef = { 23 | /** 24 | * The name of the entity being referenced. 25 | */ 26 | name: string; 27 | /** 28 | * The type of the referenced entity. 29 | */ 30 | entityType: string; 31 | } & MinimalRef; 32 | 33 | /** 34 | * Interface definition for entities. This attaches fields to common types. All 35 | * top level collections are composed of Entity instances. 36 | */ 37 | export interface Entity { 38 | uid: string; 39 | name: string; 40 | createdAtUtc?: string; 41 | updatedAtUtc?: string | null; 42 | createdBy?: EmbeddedRef; 43 | updatedBy?: EmbeddedRef; 44 | schemaVersion?: string; 45 | } 46 | 47 | /** 48 | * The type of media. A document can represent an uploaded PDF, for example. 49 | */ 50 | export type MediaType = "image" | "video" | "document"; 51 | 52 | /** 53 | * Type that represents the media for a given trip, place, or story. Media is 54 | * stored against a trip directly. Places and the story can then reference the 55 | * same media and reference the media order differently within the local scope. 56 | */ 57 | export type MediaRef = { 58 | /** 59 | * A global lexorank for the media for ordering purposes. 60 | */ 61 | rank: string; 62 | 63 | /** 64 | * A full HTTP URL that points to the media location. 65 | */ 66 | url: string; 67 | 68 | /** 69 | * The extension for the media. 70 | */ 71 | ext: string; 72 | 73 | /** 74 | * The string path of the file that corresponds to the location in Firestore. 75 | */ 76 | path?: string; 77 | 78 | /** 79 | * A display title for the media 80 | */ 81 | title?: string; 82 | 83 | /** 84 | * A caption to display for the media 85 | */ 86 | caption?: string; 87 | 88 | /** 89 | * Alternate text for the media 90 | */ 91 | alt?: string; 92 | 93 | /** 94 | * The size of the media in bytes if it is stored. 95 | */ 96 | size?: number; 97 | 98 | /** 99 | * If specified, a fixed width of the media. 100 | */ 101 | width?: number; 102 | 103 | /** 104 | * If specified, a fixed height for the media. 105 | */ 106 | height?: number; 107 | 108 | /** 109 | * The type the media. 110 | */ 111 | type: MediaType; 112 | 113 | /** 114 | * When present, this means that the media ref has been marked for removal. 115 | * We need to do this because there may be references to the media elsewhere 116 | * that we don't want to break, but when the owning container is deleted, we want 117 | * to have a full set of refs to delete. 118 | */ 119 | markAsRemovedUtc?: string; 120 | } & MinimalRef; 121 | 122 | /** 123 | * The type of content that is being referenced. 124 | */ 125 | export type ContentType = "url" | "document"; 126 | 127 | /** 128 | * Base type declaration for a profile which can be re-composed in other 129 | * contexts where we don't want the internal details of the profile state. 130 | */ 131 | export type ProfileBase = { 132 | /** 133 | * The display name associated with the profile. This allows the user to provide 134 | * a different name than the one received from the OAuth flow. 135 | */ 136 | displayName?: string; 137 | /** 138 | * The email address of the user. 139 | */ 140 | email: string; 141 | /** 142 | * When this value is undefined, the user has not yet activated a 143 | * profile created on their behalf (e.g. via invite.). Once activated, 144 | * the value is updated to the activation date. 145 | */ 146 | activatedUtc?: string; 147 | /** 148 | * A reference to a user photo. 149 | */ 150 | photo?: MediaRef; 151 | }; 152 | 153 | /** 154 | * User lifecycle events; the fields capture the date and time when the event 155 | * occurred 156 | */ 157 | export type ProfileEvent = { 158 | /** 159 | * Present then the user has watched or decined the intro video (so we don't show 160 | * it again) 161 | */ 162 | experiencedIntroVideo?: string; 163 | 164 | /** 165 | * The user experienced the survey prompt. 166 | */ 167 | experiencedSurvey?: string; 168 | }; 169 | 170 | /** 171 | * This type is used to hold boolean records like the opt-in for the email 172 | * notification so we know what the user chose and when. 173 | */ 174 | export type BooleanRecord = { 175 | /** 176 | * The user selected value. 177 | */ 178 | active: boolean; 179 | /** 180 | * The UTC date and time the record was updated. 181 | */ 182 | updatedUtc: string; 183 | }; 184 | 185 | /** 186 | * A type which represents the OAuth scopes the profile has granted. 187 | */ 188 | export type OAuthScopes = { 189 | google: string[]; 190 | }; 191 | 192 | /** 193 | * Type for archivable entities. 194 | */ 195 | export type Archivable = { 196 | /** 197 | * When present, the UTC date and time that the workspace was archived. 198 | */ 199 | archivedAtUtc?: string; 200 | } 201 | 202 | /** 203 | * Maps a user profile which represents local user configuration information. 204 | * Profiles are a top level collection. 205 | */ 206 | export type Profile = { 207 | /** 208 | * User lifecycle events which indicate that the user has performed key steps 209 | */ 210 | events?: ProfileEvent; 211 | /** 212 | * The scopes which have been granted by this user. The actual tokens are stored 213 | * elsewhere, but this allows us to determine which features to show to the user. 214 | */ 215 | scopes?: OAuthScopes; 216 | /** 217 | * When true, the user is opting in to receive emails. 218 | */ 219 | receiveEmails?: BooleanRecord; 220 | /** 221 | * User is opting in to receive feedback requests. 222 | */ 223 | receiveFeedbackRequests?: BooleanRecord; 224 | } & ProfileBase & 225 | Entity; 226 | -------------------------------------------------------------------------------- /web/content/ais-coming-industrialization-of-coding-how-teams-need-to-rethink-hiring.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-02-10 3 | author: Charles Chen 4 | description: AI coding assistants signal the industrialization of the programming profession where production of code changes from a bespoke craft to a high-output process; it's time to rethink the "operators" of the future. 5 | ogImage: /images/n-queens-solved.gif 6 | --- 7 | 8 | # AI's Coming Industrialization of Coding - How Teams Need to Rethink Hiring 9 | 10 | [{{ $doc.author }}]{.post-author } - [{{ $doc.date }}]{.post-date} 11 | 12 | [{{ $doc.description }}]{.post-desc} 13 | 14 | The age of AI coding assistants is upon us and with this comes an *industrialization* of the profession of programming and software engineering. 15 | 16 | Despite many decades of attempts to formalize the act of programming, by and large, it is still more accurate to think of it as a craft rather than an engineering discipline. UML, for example, was intended to be a transformative tool that formalized the craft through detailed descriptions of design, logic, and flow from which entire systems could be generated automatically. But decades on, its practice has largely faded having fallen far from the lofty vision of replacing coding with diagraming. 17 | 18 | To this day, it is more accurate to think of each program as bespoke and hand shaped by artisans, skilled in their craft. AI coding assistants -- even in their current fledgling state -- shift this dynamic from bespoke creation by skilled artisans to industrial operators. 19 | 20 | --- 21 | 22 | Consider the humble, every-day hardware fastener: the nail. Before the industrialization of their manufacturing, if one needed fasteners, one needed a skilled blacksmith to transform raw metals into functional hardware through a process of shaping and hammering by hand. 23 | 24 | ::quasar-image{src="/images/hand-crafted-nails.png" max-height="500px"} 25 | Prior to industrial manufacturing of fasteners, each one was hand crafted and bespoke. 26 | :: 27 | 28 | Today, the process of creating hardware fasteners is *very different* with machines capable of mass producing nearly identical fasteners. While a skilled blacksmith may produce a few hundred fasteners a day, modern industrial machines can produce *hundreds per minute*. 29 | 30 | ::quasar-video{src="https://www.youtube.com/embed/woH-m3TRakA" max-height="500px"} 31 | Modern day industrial manufacturing of hardware fasteners like nails occurs so fast that the process is only discernible when slowed down. 32 | :: 33 | 34 | Some might object that this analogy is perhaps inaccurate because a machine that produces nails can *only* produce nails. This is true; so it is rather more accurate to imagine that the LLM is like a fantastical industrial metal fabrication machine capable of producing anything the customer asks. Yet this machine still requires a skilled operator to ensure that the output is fit for purpose and meets the standards of its intended use. After all, using un-galvanized hardware outdoors in treated wood might not be such a good idea! 35 | 36 | --- 37 | 38 | The transition from the craft age to the industrial age of hardware fasteners means that it is no longer necessary to have a skilled blacksmith to produce hundreds of bespoke fasteners a day; a modern machine can produce the same volume in a fraction of that time. It follows, then, that to produce fasteners in the modern age, *a hiring process selecting for skilled blacksmiths is probably the wrong choice*; the right choice is to instead hire the "operators" that: 39 | 40 | - Oversee these industrial machines, 41 | - Maintain them as needed, 42 | - And check to ensure the quality of the output 43 | 44 | It's easy to see the parallels to how AI coding copilots will change the profession of software engineering. When an industrial machine can produce lines of code 100x or even *1000x* faster than even the most competent engineers, then it would seem that teams should consider selecting for the right "operators" of these machines that can quickly and efficiently verify quality, correctness, and fitness for purpose. 45 | 46 | As an example, the N-Queens family of leetcode challenges are both considered "hard": 47 | 48 | ::quasar-image{src="/images/n-queens-rating.png" max-height="500px"} 49 | Both are considered "hard" problems and would be challenging for anyone seeing them the first time! 50 | :: 51 | 52 | Yet GPT makes short work of this, generating the output hundreds of times faster than even a developer could -- even if the developer already knew the algorithm and solution. 53 | 54 | ::quasar-video{src="/images/n-queens-solved.mp4" max-height="600px" ratio="1.3"} 55 | Like modern machines producing nails, LLMs can produce consistent output so fast that it's hard to follow! 56 | :: 57 | 58 | This huge disparity is not unlike the gap between a blacksmith and a modern industrial machine producing nails; there's simply no comparison. Is the solution for teams to make even more convoluted and complicated puzzles? To select for the few superhuman engineers that could even solve them? Or perhaps the right direction is to embrace this coming era of industrialized coding? 59 | 60 | Companies that want to enter the fastener business today surely would not select for skilled blacksmiths, yet this seems to still be the singular focus of tech companies selecting for software engineers through leetcode. 61 | 62 | What software teams need in this dawn of the industrial era of coding are operators that understand the problem domain and the objective of the software solution. The role of the programmer changes from that of an artisan to that of a machine engineer, ensuring that the machinery churns out quality code that's fit for purpose. 63 | 64 | --- 65 | 66 | For teams that are in the process of hiring technical talent, solely focusing on an engineer's ability to craft bespoke code is perhaps the wrong way to go about it. In this age of AI coding copilots and the industrialization of the craft of programming, what teams should consider is selecting for a different set of skills that focus more on the safe, efficient operation of that fantastic machinery as well as ensure the quality of its output and fitness for purpose. 67 | 68 | Teams that fail to incorporate code reviews into their hiring process will be selecting for *"codesmiths"* in this coming era of industrial code production machines. 69 | -------------------------------------------------------------------------------- /web/pages/review/[uid].vue: -------------------------------------------------------------------------------- 1 | 122 | 123 | 202 | 203 | 208 | -------------------------------------------------------------------------------- /web/components/workspace/WorkspaceRatingDialog.vue: -------------------------------------------------------------------------------- 1 | 131 | 132 | 203 | 204 | 205 | -------------------------------------------------------------------------------- /web/stores/composables/candidates.ts: -------------------------------------------------------------------------------- 1 | import { QueryConstraint, orderBy, where } from "firebase/firestore"; 2 | import { defaultWorkspace } from "../workspaceStore"; 3 | import type { CandidateReview } from "../../../shared/domainModels"; 4 | 5 | export const defaultCandidate: CandidateReview = { 6 | uid: "default-candidate-review", 7 | name: "default", 8 | email: "default@example.com", 9 | workspaceUid: "default-workspace", 10 | workspaceName: "default", 11 | comments: {}, 12 | sources: {}, 13 | }; 14 | 15 | /** 16 | * Composable for the workspace store; contains the scope for managing candidate 17 | * reviews. 18 | */ 19 | export function useCandidates(workspace: Ref) { 20 | /** 21 | * The candidate code reviews for this workspace. 22 | */ 23 | const candidates: Ref = ref([]); 24 | 25 | /** 26 | * The active candidate review. 27 | */ 28 | const candidate: Ref = 29 | ref(defaultCandidate); 30 | 31 | /** 32 | * These are the workspaces for which the current user performed a candidate 33 | * review. 34 | */ 35 | const candidateWorkspaces: Ref = ref([]); 36 | 37 | /** 38 | * The active selection range separate from the selected comment. The selection 39 | * range can be updated by the user when selecting lines of code. 40 | */ 41 | const selection: Ref = ref(); 42 | 43 | /** 44 | * Represents the actively selected comment. When this changes, we will need to 45 | * change the selected file or the selected lines (if the file is already active) 46 | */ 47 | const selectedComment: Ref = ref(); 48 | 49 | /** 50 | * Loads a candidate from either the collection of candidates or from 51 | * the backing store. 52 | * @param uid The UID of the candidate to load 53 | */ 54 | async function ensureCandidate(uid: string) { 55 | if (uid === defaultCandidate.uid) { 56 | return; 57 | } 58 | 59 | if (candidates.value.length > 0) { 60 | const foundCandidate = candidates.value.find((c) => c.uid === uid); 61 | 62 | if (foundCandidate) { 63 | candidate.value = foundCandidate; 64 | return; 65 | } 66 | } 67 | 68 | try { 69 | const foundCandidate = await candidateReviewRepository.findByUid(uid); 70 | 71 | if (foundCandidate) { 72 | candidate.value = foundCandidate; 73 | } 74 | } catch (e) { 75 | console.error(`An error occurred while loading the candidate: ${uid}`); 76 | console.error(e); 77 | } 78 | } 79 | 80 | /** 81 | * Loads the candidates for the current workspace. To make these fields work, 82 | * they need to be reflected in an index to allow using multiple constraints. 83 | * @param candidateUid When present, this is the UID of a specific candidate to subscribe to. 84 | * @param email When present, the candidate email used for rule evaluation. 85 | * @param workspaceUid When present, the workspace UID. 86 | */ 87 | async function loadCandidates( 88 | candidateUid?: string, 89 | email?: string, 90 | workspaceUid?: string 91 | ) { 92 | if (!workspaceUid && (workspace.value.uid === defaultWorkspace.uid)) { 93 | return; 94 | } 95 | 96 | // Prevent over subscribing. 97 | const subscriptionKey = `candidates.${workspace.value.uid}` 98 | 99 | if (firebaseSubscriptions.hasSubscription(subscriptionKey)) { 100 | return 101 | } 102 | 103 | const constraints: QueryConstraint[] = [ 104 | where("workspaceUid", "==", workspaceUid ?? workspace.value.uid), 105 | orderBy("createdAtUtc", "desc") 106 | ] 107 | 108 | // These have to be reflected in an index in firestore.indexes.json 109 | if (candidateUid) { 110 | constraints.push(where("uid", "==", candidateUid)) 111 | } 112 | 113 | if (email) { 114 | constraints.push(where("email", "==", email)) 115 | } 116 | 117 | console.log(`Loading candidates for workspace: ${workspace.value.uid}`); 118 | 119 | candidates.value.splice(0, candidates.value.length); 120 | 121 | const candidatesSubscription = candidateReviewRepository.subscribe( 122 | { 123 | added: (newCandidate) => { 124 | candidates.value.push(newCandidate); 125 | }, 126 | modified: (modifiedCandidate) => { 127 | findAndSplice(candidates.value, modifiedCandidate, true); 128 | findAndMerge(candidate.value, modifiedCandidate); 129 | }, 130 | removed: (removedCandidate) => { 131 | findAndSplice(candidates.value, removedCandidate, false); 132 | }, 133 | }, 134 | ...constraints 135 | ); 136 | 137 | firebaseSubscriptions.register( 138 | subscriptionKey, 139 | candidatesSubscription 140 | ); 141 | } 142 | 143 | /** 144 | * When present, loads the workspaces for which the user is a candidate. In 145 | * other words, the workspaces that the user previously provided a review. 146 | * @param email The email address of the currently logged in user. 147 | */ 148 | async function loadCandidateWorkspaces( 149 | email: string 150 | ) { 151 | // Prevent over subscribing. 152 | const subscriptionKey = `candidates.${email}` 153 | 154 | if (firebaseSubscriptions.hasSubscription(subscriptionKey)) { 155 | return 156 | } 157 | 158 | const constraints: QueryConstraint[] = [ 159 | where("email", "==", email), 160 | orderBy("createdAtUtc", "desc") 161 | ] 162 | 163 | console.log(`Loading for workspaces for candidate: ${email}`); 164 | 165 | candidateWorkspaces.value.splice(0, candidateWorkspaces.value.length); 166 | 167 | const candidatesSubscription = candidateReviewRepository.subscribe( 168 | { 169 | added: (newCandidate) => { 170 | candidateWorkspaces.value.push(newCandidate); 171 | }, 172 | modified: (modifiedCandidate) => { 173 | findAndSplice(candidateWorkspaces.value, modifiedCandidate, true); 174 | }, 175 | removed: (removedCandidate) => { 176 | findAndSplice(candidateWorkspaces.value, removedCandidate, false); 177 | }, 178 | }, 179 | ...constraints 180 | ); 181 | 182 | firebaseSubscriptions.register( 183 | subscriptionKey, 184 | candidatesSubscription 185 | ); 186 | } 187 | 188 | return { 189 | candidates, 190 | candidate, 191 | selection, 192 | selectedComment, 193 | ensureCandidate, 194 | loadCandidates, 195 | loadCandidateWorkspaces, 196 | candidateWorkspaces 197 | }; 198 | } 199 | -------------------------------------------------------------------------------- /web/content/improving-your-interview-process-buy-the-pecans-skip-the-trail-mix.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-01-27 3 | author: Charles Chen 4 | description: Interview processes have become increasingly long and convoluted. Is it possible to short circuit the extraneous effort and condense the process without sacrificing on selection for quality and talent? 5 | ogImage: /images/candidate-fail.png 6 | --- 7 | 8 | # Improving Your Interview Process: Buy the Pecans; Skip the Trail Mix 9 | 10 | [{{ $doc.author }}]{.post-author } - [{{ $doc.date }}]{.post-date} 11 | 12 | [{{ $doc.description }}]{.post-desc} 13 | 14 | If you turn off your ad blocker and browse the modern web, you might be surprised by the amount of extra cruft you have to wade through before you can get to the actual content that you're interested in. 15 | 16 | Even with ad blockers on, sites that feature recipes (for example) often stuff the recipe page with paragraphs of exposition for the purpose of SEO ranking. What we really care for is the actual, useful content -- just give me the recipe! 17 | 18 | There's not much we can do about those recipe sites, but if you find yourself favoring the pecans in your bag of trail mix, *you might consider just buying a bag of pecans* rather than picking around the rest of the trail mix. 19 | 20 | --- 21 | 22 | Technical interviews have become like recipe sites, often bloated with extraneous effort for all parties involved. 23 | 24 | Consider the take home project which can take anywhere from a few hours of effort to even a few days! 25 | 26 | - The candidate must decide if it's worth their time and effort to spend 2-4+ hours working on a take home project. Strong candidates with multiple options may choose to simply skip one that requires them to commit extra time. (Kudos to the teams that are thoughtful enough to compensate candidates for their time) 27 | - Once submitted, the hiring team must then go back and review a relatively large body of code and pore over it. 28 | - Because candidates might have used AI or asked someone for help, now another live session has to be scheduled to do a hands-on session to ascertain whether the candidate understands the code. 29 | 30 | End-to-end, this process can span over a week! 31 | 32 | In the end, for the reviewer, there are clearly only a few key points in the submission that receive more scrutiny and provide the key insights. Data access code is typically one of the key areas of focus for a backend system as would be API input validation and exception handling. The rest of the scaffolding to implement a functioning project? It's largely chaff like the exposition in the recipe and the peanuts in the trail mix; filler added to bulk the mix (well, unless you like the peanuts!). 33 | 34 | > Consider just reviewing some AI generated code in the first place 35 | 36 | With the quality and capability of AI code generation improving rapidly, what value is there remaining in the take home project if the immediate next session is to review the code with the candidate anyways? 37 | 38 | Would it not be much more efficient to simply focus on the key points that matter and skip the rest? Or even consider just reviewing some AI generated code in the first place? 39 | 40 | ::quasar-image{src="/images/candidate-fail.png" max-height="500px"} 41 | Overly drawn out and complex processes can be a self-inflicted fail. 42 | :: 43 | 44 | --- 45 | 46 | It is endlessly fascinating that in an industry so focused on efficiency and removing extraneous effort from processes, that tech interviews have seemingly gone the other way and have instead continued to increase in complexity and friction. Much like the trend of defaulting to microservices, it is seemingly a self-inflicted wound. 47 | 48 | Long, complex processes mean that teams may lose otherwise strong candidates to attrition as the process draws out. Many good candidates will skip the opportunity entirely due to lack of time to commit to a long take home or process. Exercises like a take home also require more effort on behalf of the hiring team to review the submission -- a self inflicted wound indeed! 49 | 50 | A simple solution to this is to just change the process to focus on those key insights that matter. When reviewing a take home submission for a backend API, what would really signal a strong candidate? If you have a concrete answer in mind to this question, then it seems like many hours -- even whole followup sessions -- could be shaved from the process by directly focusing on eliciting that response from the candidate. If you do not have a concrete answer to this question, then perhaps there is a bigger problem as it may indicate that there is a lack of clarity on what separates a strong candidate submission from simply a working one. 51 | 52 | --- 53 | 54 | As a concrete example, when I interview candidates for C#, I have a set of go-to questions that I have found strongly correlate with a candidate's experience and level of expertise. One such series of questions focuses on C# generics and `Func` and `Action` delegates. 55 | 56 | 1. Describe what `Func` and `Action` are in C#? 57 | 2. Given a function that takes a `Func` or `Action`, invoke it. 58 | 3. Write a trivial function that takes a `Func` or `Action`. 59 | 60 | The objective is to see if the candidate understands generics and function delegates; there's no need to have a convoluted process around this since this exercise can be fulfilled in < 5 minutes. 61 | 62 | Likewise, another topic of interest is concurrency and similarly, we can have a simple series of questions which directly seeks to surface the candidate's experience and knowledge of concurrency. 63 | 64 | 1. What does `Task` represent? 65 | 2. Write a trivial function that returns a `Task` 66 | 3. Write a trivial function that takes a `Func>` 67 | 4. Write a block of code that invokes the function 10 times concurrently and print a message when all 10 executions complete. 68 | 69 | These simple exercises can be done together in under 10 minutes, skipping several hours of time investment by both the candidate and the interviewer without losing key technical insights into the candidate's level of experience for a given language or platform. 70 | 71 | What about more abstract qualities of the code? It is similarly possible to design smaller, tighter exercises that focus on measuring for the desired positive signal instead of requiring the extra scaffolding. For example, if extensibility is a key point of interest, simply design a small, self encapsulated exercise that would produce the target response that would be of interest in a larger project and do that smaller exercise instead. 72 | 73 | --- 74 | 75 | If you find yourself favoring the pecans in your trail mix, *it seems like you might prefer just buying a bag of pecans*. Similarly, if you find that there are a few key patterns, concepts, or techniques that generate a strong positive signal in a take home or leetcode exercise, consider designing an alternative assessment that seeks to directly elicit and measure for this output. 76 | 77 | Using a targeted approach can help reduce the time commitment for both sides without sacrificing signal resolution while also reducing candidate attrition in long, drawn out interview processes. 78 | -------------------------------------------------------------------------------- /web/stores/workspaceStore.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { deleteField, orderBy, where } from "firebase/firestore"; 3 | import { sourceStorage } from "../utils/data/Storage"; 4 | import { useCandidates } from "./composables/candidates"; 5 | 6 | export const defaultWorkspace: Workspace = { 7 | uid: "default-workspace", 8 | name: "default", 9 | description: "default", 10 | collaborators: {}, 11 | sources: {} 12 | } 13 | 14 | export const useWorkspaceStore = defineStore("useWorkspaceStore", () => { 15 | const appStore = useAppStore(); 16 | 17 | const workspacesLoaded = ref(false); 18 | 19 | const workspaces = ref([]); 20 | 21 | const workspace = ref({...defaultWorkspace}); 22 | 23 | const selectedSourceFile = ref(); 24 | 25 | let skipNextUpdate = false 26 | 27 | /** 28 | * Resets the state of the workspace stores. 29 | */ 30 | function reset() { 31 | workspacesLoaded.value = false 32 | workspaces.value = [], 33 | workspace.value = {...defaultWorkspace} 34 | } 35 | 36 | /** 37 | * Ensures that the workspace is loaded. 38 | * @param uid The UID of the workspace to load. 39 | */ 40 | async function ensureWorkspace(uid: string) { 41 | if (workspace.value && workspace.value.uid === uid) { 42 | return; 43 | } 44 | 45 | // Unsubscribe the candidates when we switch workspaces. 46 | // ? Maybe there's a better place to do this? 47 | if (workspace.value.uid !== defaultWorkspace.uid) { 48 | firebaseSubscriptions 49 | .unsubscribe(`candidates.${workspace.value.uid}`) 50 | } 51 | 52 | console.log(`Ensuring workspace: ${uid}`) 53 | 54 | if (workspaces.value.length > 0) { 55 | const foundWorkspace = workspaces.value.find((w) => w.uid === uid); 56 | 57 | if (foundWorkspace) { 58 | workspace.value = foundWorkspace; 59 | return; 60 | } 61 | } 62 | 63 | try { 64 | const foundWorkspace = await workspaceRepository.findByUid(uid); 65 | 66 | if (foundWorkspace) { 67 | workspace.value = foundWorkspace; 68 | } 69 | } catch (e) { 70 | console.error(`An error occurred while loading the workspace: ${uid}`); 71 | console.error(e); 72 | } 73 | } 74 | 75 | /** 76 | * Loads the workspaces for the current profile. These are owned workspaces for 77 | * the user meaning that they are collaborators or they created the workspace. 78 | */ 79 | async function loadWorkspaces() { 80 | if (workspacesLoaded.value) { 81 | return; // Already loaded 82 | } 83 | 84 | const { profile } = useAppStore() 85 | 86 | if (!profile || profile.uid === defaultProfile.uid) { 87 | return; 88 | } 89 | 90 | const subscriptionKey = `workspaces.${profile.uid}` 91 | 92 | if (firebaseSubscriptions.hasSubscription(subscriptionKey)) { 93 | return 94 | } 95 | 96 | workspaces.value.splice(0, workspaces.value.length); 97 | 98 | const workspacesSubscription = workspaceRepository.subscribe( 99 | { 100 | added: (newTrip) => { 101 | workspaces.value.push(newTrip); 102 | }, 103 | modified: (modifiedWorkspace) => { 104 | if (skipNextUpdate) { // This skips the update for a file rename; we update it locally 105 | skipNextUpdate = false 106 | } else { 107 | findAndSplice(workspaces.value, modifiedWorkspace, true); 108 | findAndMerge(workspace.value, modifiedWorkspace); 109 | } 110 | }, 111 | removed: (removedWorkspace) => { 112 | findAndSplice(workspaces.value, removedWorkspace, false); 113 | }, 114 | }, 115 | where(`collaborators.${profile.uid}`, "!=", "") 116 | ); 117 | 118 | firebaseSubscriptions.register( 119 | subscriptionKey, 120 | workspacesSubscription 121 | ); 122 | 123 | workspacesLoaded.value = true; 124 | } 125 | 126 | /** 127 | * Adds a file to the current workspace. 128 | * @param file The file to add 129 | */ 130 | async function addSource(file: File) { 131 | if (!workspace.value) { 132 | return 133 | } 134 | 135 | const result = await sourceStorage.addSourceFile(workspace.value.uid, file); 136 | 137 | console.log('Added file') 138 | console.log(result) 139 | 140 | await workspaceRepository.updateFields(workspace.value.uid, { 141 | [`sources.${result.uid}`] : { 142 | uid: result.uid, 143 | type: "document", 144 | path: result.path, 145 | url: result.url, 146 | name: file.name, // This is the original name. 147 | entityType: "document", 148 | addedUtc: dayjs.utc().toISOString() 149 | } 150 | }) 151 | } 152 | 153 | /** 154 | * Removes a source document. 155 | * @param sourceUid The UID of the source to remove. 156 | * @param path The path of the file to remove from the backing store. 157 | */ 158 | async function removeSource( 159 | sourceUid: string, 160 | path: string 161 | ) { 162 | if (!workspace.value) { 163 | console.log("No active workspace"); 164 | return; 165 | } 166 | 167 | console.log( 168 | `Removing source: ${sourceUid} from workspace ${workspace.value.uid}` 169 | ); 170 | 171 | await workspaceRepository.updateFields(workspace.value.uid, { 172 | [`sources.${sourceUid}`]: deleteField(), 173 | }); 174 | } 175 | 176 | /** 177 | * Deletes a workspace and all of the artifacts associated with the workspace. 178 | * TODO: Move this to backend? 179 | * @param uid The UID of the workspace to delete 180 | * @param files The files for the workspace which need to be deleted. 181 | */ 182 | async function deleteWorkspace(uid: string, files: MediaRef[]) { 183 | // Delete the candidates. 184 | const candidates = await candidateReviewRepository 185 | .findByFilter(where("workspaceUid", "==", uid)) 186 | 187 | for (const c of candidates) { 188 | await candidateReviewRepository.deleteById(c.uid) 189 | } 190 | 191 | // Delete the files 192 | for (const file of files) { 193 | if (!file.path) { 194 | continue 195 | } 196 | 197 | await sourceStorage.deleteFile(file.path) 198 | } 199 | 200 | // Delete the workspace 201 | await workspaceRepository.deleteById(uid) 202 | } 203 | 204 | /** 205 | * Updates the name of the file with the given hash for the current workspace. 206 | * @param hash The hash of the file to update. 207 | * @param newNameWithExt The new name to assign to the file (including the extension) 208 | */ 209 | async function updateFileName( 210 | hash: string, 211 | newNameWithExt: string 212 | ) { 213 | console.log( 214 | `Updating file with hash: ${hash} with new name ${newNameWithExt}` 215 | ); 216 | 217 | skipNextUpdate = true 218 | 219 | await workspaceRepository.updateFields( 220 | workspace.value.uid, { 221 | [`sources.${hash}.title`]: newNameWithExt 222 | } 223 | ) 224 | } 225 | 226 | return { 227 | workspace, 228 | workspaces, 229 | workspacesLoaded, 230 | ensureWorkspace, 231 | loadWorkspaces, 232 | addSource, 233 | removeSource, 234 | deleteWorkspace, 235 | updateFileName, 236 | selectedSourceFile, 237 | reset, 238 | ...useCandidates(workspace) 239 | }; 240 | }); 241 | -------------------------------------------------------------------------------- /web/utils/data/Repository.ts: -------------------------------------------------------------------------------- 1 | import { firebaseConnector } from "./FirebaseConnector"; 2 | import { 3 | collection, 4 | deleteDoc, 5 | doc, 6 | type DocumentChangeType, 7 | type DocumentData, 8 | FieldValue, 9 | Firestore, 10 | getDoc, 11 | getDocs, 12 | onSnapshot, 13 | query, 14 | QueryConstraint, 15 | setDoc, 16 | updateDoc, 17 | } from "firebase/firestore"; 18 | import type { Unsubscribe } from "firebase/auth"; 19 | import dayjs from "dayjs"; 20 | 21 | /** 22 | * Type used to represent an entity update that allows for deletion 23 | */ 24 | export type EntityUpdate ={ [P in keyof T]?: T[P] | undefined | FieldValue } 25 | 26 | /** 27 | * Replaces an entity in a collection with a new one that has been updated. 28 | * @param collection The collection to splice 29 | * @param entity The entity that needs to be spliced out 30 | * @param replace The entity to replace it with. 31 | * @returns The index at which the entity was found and spliced. 32 | */ 33 | export function findAndSplice( 34 | collection: T[], 35 | entity: T, 36 | replace: boolean 37 | ) { 38 | let index = collection.findIndex((e) => e.uid === entity.uid); 39 | let removed: T[] = []; 40 | 41 | if (index < 0) { 42 | collection.push(entity); 43 | index = collection.length - 1; 44 | } else if (replace) { 45 | removed = collection.splice(index, 1, entity); 46 | } else { 47 | removed = collection.splice(index, 1); 48 | } 49 | 50 | return { index, removed }; 51 | } 52 | 53 | /** 54 | * Searches an entity collection for a match and then performs a key-by- 55 | * key merge on the entity instead of replacing it. This can prevent 56 | * some cases of cross updates causing one side to lose work when the 57 | * entire entity is replaced. 58 | * @param target The entity collection to search for the entity. 59 | * @param source The modified entity; source of incoming changes 60 | */ 61 | export function findAndMerge(target: T, source: T) { 62 | // Don't replace the whole object; make a key-by-key update. If we 63 | // replace the whole object, we'll get a more jarring screen update. 64 | for (const key of Object.keys(source)) { 65 | if ((target as any)[key] === (source as any)[key]) { 66 | continue; 67 | } 68 | 69 | if ( 70 | typeof (source as any)[key] === "object" && 71 | JSON.stringify((target as any)[key]) === 72 | JSON.stringify((source as any)[key]) 73 | ) { 74 | continue; 75 | } 76 | 77 | (target as any)[key] = (source as any)[key]; 78 | } 79 | } 80 | 81 | /** 82 | * Base class for repositories. Provides abstractions for common CRUD operations and a 83 | * container for encapsulating the queries. 84 | */ 85 | export abstract class Repository { 86 | protected readonly db: Firestore; 87 | 88 | constructor(db?: Firestore) { 89 | if (db) { 90 | this.db = db; 91 | } else { 92 | this.db = firebaseConnector.db; 93 | } 94 | } 95 | 96 | /** 97 | * Inheriting classes override this class to provide the collection root. 98 | */ 99 | protected abstract get collectionRoot(): string; 100 | 101 | /** 102 | * Retrieves a document from the collection using the UID. 103 | * @param uid The UID of the document to find. 104 | * @returns The document that matches the UID. 105 | */ 106 | public async findByUid(uid: string): Promise { 107 | const docRef = doc(this.db, this.collectionRoot, uid); 108 | const docSnap = await getDoc(docRef); 109 | return docSnap.exists() ? (docSnap.data() as T) : undefined; 110 | } 111 | 112 | /** 113 | * Creates an instance of document in Firestore. The schema version, created 114 | * by, created at are set automatically. 115 | * @param entity The document entity to create. 116 | * @returns The instance that was created. 117 | */ 118 | public async create(entity: T): Promise { 119 | const { profile } = useAppStore(); 120 | 121 | if (!profile) { 122 | throw Error("No profile available; user is not logged in."); 123 | } 124 | 125 | entity.schemaVersion = SCHEMA_VERSION; 126 | 127 | if (!entity.createdBy) { 128 | entity.createdBy = { 129 | uid: profile.uid, 130 | name: profile.name, 131 | addedUtc: dayjs.utc().toISOString(), 132 | entityType: "user", 133 | }; 134 | } 135 | 136 | if (!entity.createdAtUtc) { 137 | entity.createdAtUtc = dayjs.utc().toISOString(); 138 | } 139 | 140 | await setDoc(doc(this.db, this.collectionRoot, entity.uid), entity); 141 | 142 | return entity; 143 | } 144 | 145 | /** 146 | * Updates the document instance matching the entity UID in Firestore. The 147 | * updated by and updated at are set automatically. 148 | * @param entity The document entity to update. 149 | */ 150 | public async update(entity: T) { 151 | const { profile } = useAppStore(); 152 | 153 | if (!profile) { 154 | throw Error("No profile available; user is not logged in."); 155 | } 156 | 157 | entity.updatedBy = { 158 | uid: profile.uid, 159 | name: profile.name, 160 | addedUtc: dayjs.utc().toISOString(), 161 | entityType: "user", 162 | }; 163 | 164 | entity.updatedAtUtc = dayjs.utc().toISOString(); 165 | 166 | await setDoc(doc(this.db, this.collectionRoot, entity.uid), entity); 167 | } 168 | 169 | /** 170 | * Updates a single field on the document. The update need not include all fields 171 | * so we can accept a partial field. 172 | * @param uid The UID of the entity to update; does not need to be attached to the entity 173 | * @param entity The document entity to update. 174 | */ 175 | public async updateFields( 176 | uid: string, 177 | entity: EntityUpdate 178 | ) { 179 | const { profile } = useAppStore(); 180 | 181 | if (!profile) { 182 | throw Error("No profile available; user is not logged in."); 183 | } 184 | 185 | entity.updatedBy = { 186 | uid: profile.uid, 187 | name: profile.name, 188 | addedUtc: dayjs.utc().toISOString(), 189 | entityType: "user", 190 | } as EmbeddedRef; 191 | 192 | entity.updatedAtUtc = dayjs.utc().toISOString(); 193 | 194 | const docRef = doc(this.db, this.collectionRoot, uid); 195 | await updateDoc(docRef, entity as DocumentData); 196 | } 197 | 198 | /** 199 | * Creates a subscription to the underlying collection to handle changes. This 200 | * subscription should be registered with firebaseSubscriptions! 201 | * @param handlers The event handlers for the subscription 202 | * @param condition A set of conditions to filter the subscription 203 | * @returns The subscription snapshot. 204 | */ 205 | public subscribe( 206 | handlers: Record void>, 207 | ...condition: QueryConstraint[] 208 | ): Unsubscribe { 209 | const q = query(collection(this.db, this.collectionRoot), ...condition); 210 | 211 | return onSnapshot(q, (snapshot) => { 212 | for (const docChange of snapshot.docChanges()) { 213 | handlers[docChange.type](docChange.doc.data() as T); 214 | } 215 | }); 216 | } 217 | 218 | /** 219 | * Filters the document collection using the specified set of conditions. 220 | * @param condition An array of conditions which should be used as the query filter. 221 | */ 222 | public async findByFilter( 223 | ...condition: QueryConstraint[] 224 | ): Promise> { 225 | const docRef = collection(this.db, this.collectionRoot); 226 | const q = query(docRef, ...condition); 227 | const snapshot = await getDocs(q); 228 | 229 | const records = new Array(); 230 | 231 | snapshot.forEach((doc) => { 232 | records.push(doc.data() as T); 233 | }); 234 | 235 | return records; 236 | } 237 | 238 | /** 239 | * Deletes an entity document from the collection using the UID of the document. 240 | * @param uid The UID of the entity to delete 241 | */ 242 | public async deleteById(uid: string) { 243 | await deleteDoc(doc(this.db, this.collectionRoot, uid)); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /web/content/the-impact-of-ai-on-the-technical-interview-process.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-01-20 3 | author: Charles Chen 4 | description: AI's increasing competency at complex tasks like coding creates a boon for teams that want to harness AI to boost productivity. However, many teams haven't stopped to rethink their technical candidate screening and qualification process to account for these rapid changes. 5 | --- 6 | 7 | # The Impact of AI on the Technical Interview Process 8 | 9 | [{{ $doc.author }}]{.post-author} - [{{ $doc.date }}]{.post-date} 10 | 11 | [{{ $doc.description }}]{.post-desc} 12 | 13 | At the end of 2024, the startup I had joined was slowly winding down in an acqui-hire. As a part of this process, the entire engineering team would need to go through rounds of technical interviews for the purpose of leveling at a potential acquirer. 14 | 15 | To prepare for this, the team was told to prepare by studying leetcode and system design as these were known to be a part of the process for several of the potential acquiring companies. 16 | 17 | Candidates that have been through this process are surely familiar with Gayle McDowell's *Cracking the Coding Interview* which provides thorough strategies, tips, and refreshers for many experienced engineers who may be far removed from these types of algorithmic brain teasers. 18 | 19 | Other common resources include leetcode.com, hackerrank.com, [IGotAnOffer on YT](https://www.youtube.com/@IGotAnOffer-Engineering) and a host of other similar services that facilitate this cat-and-mouse game of simply "teaching the test". 20 | 21 | --- 22 | 23 | What is clear after going through several days of reading McDowell's book, practice sessions on leetcode.com, and watching YouTube videos on mock system design interviews is just how banal the whole process is. Don't get me wrong, leetcode.com was actually *kinda fun* doing it at my own pace, comparing my submissions with others, and working to solve these puzzles. But as a candidate selection tool? There was no congruency to much of the volume of code that I had shipped in my 20 year career. 24 | 25 | The system design ones were somehow even more outrageous given that system design is often the deciding factor between a mid/senior level and a staff/principal level engineer. Yet watching the IGotAnOffer video series successively reveals just how *formulaic* and predictable the response and banter is designed to be. 26 | 27 | To distill it, you can probably think about it in 10 steps: 28 | 29 | 1. Ascertain the non-functional requirements (# of users, volume, etc.) 30 | 2. Dive into the stated requirements and clarify 31 | 3. Make up some obviously complex requirements that will be "out of scope" 32 | 4. Draw a database -- don't forget to either have read replicas or shard to scale 33 | 5. Draw a cluster of application services (of course you need a cluster to scale it!) 34 | 6. Draw an application load balancer that's in front of the cluster 35 | 7. Draw an API gateway/WAF in front of that to filter traffic 36 | 8. Draw a CDN to efficiently cache and serve binary content 37 | 9. Draw an in memory cache like Redis or Memcached for frequently read data to prevent database reads 38 | 10. Draw a queue somewhere so you can throttle, buffer, coordinate, or sequence some mutations. 39 | 40 | Watching the videos, it was pretty clear that this is the basic formula with only minor variations by domain. Why? Given the typical 60-90 minute session, how deep can you actually go with a topic as complex as system design? Not very. So the compromise is that the design remains at a high level and thus ***any backend API will pretty much look like bullets 4-10***. 41 | 42 | While the coding challenges themselves are not nearly as scripted as the system design interview, if you spend enough time working on them you will get a sense for the general classes of solution patterns and I think that doing this correct classification step is probably the most important one in most cases when working on leetcode challenges. 43 | 44 | Indeed, with a few weeks of practice, interviews with several large, public companies were a relative breeze (particularly system design!). 45 | 46 | Had any of this practice and training had any effect on my software development skills? Had it sharpened my programming prowess? Did I learn anything new about the art and science of software engineering? Was I now a better programmer for having gone through this rigorous training. *Not one iota*. 47 | 48 | --- 49 | 50 | These processes seem to perpetuate in a vacuum as if ignoring how rapidly AI is changing the field of software engineering with advancements month-over-month if not seemingly weekly! While AI may not *(yet)* be the right tool to build whole systems, it is already quite competent in both autocompletion as well as generation of standalone units of code and can solve N-Queens hundreds of times faster than I could! 51 | 52 | > This last month is the first time that an LLM could "one shot" their second round assessment. 53 | 54 | In a discussion with one of the senior hiring managers, the topic of AI came up and he shared some interesting perspective. Periodically, this manager takes their battery of coding assessments and runs them through an LLM to see how well it does at solving the first and second round questions and he shared that this last month is the first time that an LLM could "one shot" their second round assessment. 55 | 56 | Given this reality, the question is: ***now what?*** 57 | 58 | It's not primarily a matter of cheating *per se*, but one of relevancy. When off-the-shelf, every-day AI tools can perform a given task and perform it exceedingly fast and exceedingly well, what is the value in measuring a human against it? 59 | 60 | Is the right answer to simply design a more complex set of assessments? To find the superhuman coders that can outperform an LLM coding assistant? To make it *even more Rube Goldberg*? *To what end?* 61 | 62 | Throughout this whole process of interviewing with a handful of companies, it was surprising that not once was a code review incorporated into the process. Yet proficiency at code reviews (or reading and evaluating code in general) is one of the most practical first-day and day-to-day skills -- **especially** for evaluating senior engineers. 63 | 64 | Code reviews seem to have several benefits that address several challenges in the interview process. 65 | 66 | For starters, code reviews naturally focus more on communication and collaboration versus coding challenges which bias more towards deep focus. Coding challenges are ironically a *terrible* vehicle for evaluating how well a candidate thinks via communication because it is so unnatural to talk while coding. 67 | 68 | Code reviews also have the benefit that it allows for measuring both more depth and breadth in one pass. For example, a code review of a small React app, an API, and a database schema can easily identify a candidate's proficiency bias in the stack while still allowing measurement of the depth of their knowledge. 69 | 70 | - Incorporate some obvious bugs in the React app and some not so obvious ones. 71 | - Leave off some validation in the API or exclude exception handling entirely. 72 | - Design a database and choose some obviously wrong data types and leave off some indices; see if they can make the connection from the API call pattern to the database indices that should be created or a data type that should be refactored. 73 | 74 | The variations are endless yet practical; a reflection of real day-to-day skills. In a code review format, it's OK if a candidate doesn't know the exact answer how to fix or address a particular gap; this is much like real life where we might know *"I should probably put an index here"* and then look at the docs, use Google, or an AI to ascertain the exact type or kind of index. What's nice about this format is that *maybe the candidate actually does know some deep minutiae*. All the better that a code review format allows you to measure a candidate that can identify the problem and a candidate that also knows some deep knowledge about the language, platform, tooling, or best practices. 75 | 76 | Perhaps more importantly, as the field of software engineering inevitably shifts to increased and widespread adoption of AI powered tools in building software, it would seem that a key skill to actively screen for is proficiency in reading and evaluating code along the vectors of correctness, security, performance, best practices, and so on. 77 | 78 | It is indeed true that the ability to read and evaluate code is not the same as the ability to write and organize code, but in this emerging age of AI generated code, *does this distinction carry the same weight*? 79 | 80 | --- 81 | 82 | As AI continues to progress and advance, it seems inevitable that engineering teams will need to adjust their technical screening processes with an understanding that the shift in how engineers build software is already well underway. 83 | 84 | AI's increasing competency at coding means that rather than selecting purely for the ability to solve complex coding puzzles and algorithmic challenges, more teams should start to consider how well the human shepherds are at evaluating the quality of that voluminous output. 85 | 86 | Rather than entirely shifting away from existing processes, teams should start to consider incorporating code reviews as a key step in the candidate selection process to identify those with a competency for effectively evaluating code. 87 | -------------------------------------------------------------------------------- /web/components/workspace/WorkspaceCandidateDialog.vue: -------------------------------------------------------------------------------- 1 | 164 | 165 | 288 | 289 | 302 | -------------------------------------------------------------------------------- /web/pages/home.vue: -------------------------------------------------------------------------------- 1 | 196 | 197 | 290 | 291 | 292 | -------------------------------------------------------------------------------- /web/components/workspace/WorkspaceComments.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 303 | 304 | 317 | --------------------------------------------------------------------------------