├── docs ├── index.html ├── _config.yml ├── cherry-workflow.gif └── logo.svg ├── assets ├── cherry-128.png ├── manifest.json ├── hot-reload.js ├── index.html └── logo.svg ├── src ├── service │ ├── Messages.ts │ ├── Storage.ts │ ├── CherryPie.ts │ └── Github.ts ├── components │ ├── PopupRoot.tsx │ ├── SliceBtn.tsx │ ├── Root.tsx │ ├── Settings.tsx │ ├── CloseBtn.tsx │ ├── Routes.tsx │ ├── App.tsx │ ├── Files.tsx │ ├── Execute.tsx │ ├── Review.tsx │ └── Login.tsx ├── extension │ ├── Popup.tsx │ └── Content.tsx ├── hooks │ ├── currentPr.ts │ ├── domUpdated.ts │ ├── emitter.ts │ ├── windowLocation.ts │ ├── auth.ts │ ├── slices.ts │ ├── cherry.ts │ ├── login.ts │ └── storage.ts ├── constants │ └── index.ts └── context │ ├── GlobalStore.tsx │ ├── GlobalState.tsx │ ├── PullRequest.tsx │ └── Cherry.tsx ├── .storybook ├── preview-head.html ├── config.js └── webpack.config.js ├── config ├── custom-environment-variables.json └── default.json ├── jest-puppeteer.config.js ├── jest.chrome.js ├── .babelrc ├── jest.config.js ├── .releaserc ├── tsconfig.json ├── LICENSE ├── .gitignore ├── test ├── service │ ├── CherryPie.spec.ts │ └── Github.spec.ts ├── storybook │ └── index.tsx ├── puppeteer │ └── index.live.ts └── mocks │ └── pr.ts ├── webpack.config.js ├── package.json ├── .circleci └── config.yml ├── README.md └── privacy.txt /docs/index.html: -------------------------------------------------------------------------------- 1 | Test docs -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /assets/cherry-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shlomokraus/cherrypie/HEAD/assets/cherry-128.png -------------------------------------------------------------------------------- /docs/cherry-workflow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shlomokraus/cherrypie/HEAD/docs/cherry-workflow.gif -------------------------------------------------------------------------------- /src/service/Messages.ts: -------------------------------------------------------------------------------- 1 | export class MessagesService { 2 | 3 | print(text) { 4 | console.log(text); 5 | } 6 | } -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /config/custom-environment-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "username": "GITHUB_USERNAME", 4 | "password": "GITHUB_PASSWORD" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/components/PopupRoot.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | export function PopupRoot() { 4 | return ( 5 |
PopUp
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/extension/Popup.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import React from "react"; 3 | 4 | import { PopupRoot } from "../components/PopupRoot"; 5 | 6 | ReactDOM.render(, document.getElementById('app')); 7 | 8 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | function loadStories() { 4 | require('../test/storybook/index.tsx'); 5 | // You can require as many stories as you need. 6 | } 7 | 8 | configure(loadStories, module); -------------------------------------------------------------------------------- /src/hooks/currentPr.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useContext } from "react"; 2 | import { PrContext } from "../context/PullRequest"; 3 | 4 | export const useCurrentPr = (number?) => { 5 | const {pr, error} = useContext(PrContext); 6 | return { pr, error } 7 | } -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const VERIFY_FAILED_EX = "Verification Failed"; 2 | export const MISSING_CREDENTIALS = "Missing Credentials"; 3 | 4 | export enum ProcessStatus { 5 | Idle = "Idle", 6 | Working = "Working", 7 | Done = "Done", 8 | Failed = "Failed" 9 | } -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = (baseConfig, env, config) => { 3 | config.module.rules.push({ 4 | test: /\.tsx?$/, 5 | loader: 'babel-loader', 6 | }); 7 | config.resolve.extensions.push('.ts', '.tsx'); 8 | return config; 9 | }; -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const path = require("path"); 3 | 4 | 5 | module.exports = { 6 | launch: { 7 | headless: false, 8 | args: [ 9 | '--disable-extensions-except=' + path.resolve("./dist"), 10 | '--load-extension=' + path.resolve("./dist") 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /jest.chrome.js: -------------------------------------------------------------------------------- 1 | const base = require("./jest.config.js"); 2 | 3 | module.exports = Object.assign(base, { 4 | testRegex: "(/__tests__/.*|(\\.|/)(live))\\.(js?|ts?)$", 5 | globalSetup: "jest-environment-puppeteer/setup", 6 | globalTeardown: "jest-environment-puppeteer/teardown", 7 | testEnvironment: "jest-environment-puppeteer" 8 | }); 9 | -------------------------------------------------------------------------------- /src/hooks/domUpdated.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useDomUpdated = ():MutationEvent => { 4 | const [nodes, setNodes] = useState(undefined); 5 | 6 | useEffect(() => { 7 | document.addEventListener("DOMNodeInserted", setNodes, false); 8 | 9 | }, []); 10 | 11 | return nodes; 12 | }; 13 | -------------------------------------------------------------------------------- /src/hooks/emitter.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useGlobalState} from "../context/GlobalState"; 3 | import { clone } from "lodash"; 4 | import { useSlices } from "./slices"; 5 | 6 | export const useEmitter = (emitter) => { 7 | const {addSlice} = useSlices(); 8 | useEffect(() => { 9 | emitter.on("file-slice", filename => { 10 | addSlice(filename); 11 | }); 12 | }, [emitter]); 13 | } 14 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "owner": "shlomokraus", 4 | "repo": "cherrypie-test", 5 | "number": 1, 6 | "refPrefix": "heads" 7 | }, 8 | "test": { 9 | "live": { 10 | "url": "https://github.com/shlomokraus/cherrypie-test/pull/1/files" 11 | }, 12 | "slice": { 13 | "paths": ["file1.js"], 14 | "sourceBranch": ["test-branch"], 15 | "targetBranch": ["test-branch-sliced"], 16 | "baseBranch": "master" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", 4 | { 5 | "useBuiltIns": "usage", 6 | "targets": { 7 | "browsers": [ 8 | "last 2 versions" 9 | ] 10 | } 11 | } 12 | ], 13 | "@babel/typescript", 14 | "@emotion/babel-preset-css-prop" 15 | ], 16 | "plugins": [ 17 | ["emotion", { 18 | "sourceMap": true 19 | }], 20 | ["@babel/plugin-proposal-decorators", { 21 | "legacy": true 22 | }], 23 | "@babel/plugin-proposal-class-properties" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cherry Pie", 3 | "version": "0.0.1", 4 | "manifest_version": 2, 5 | "background": { 6 | "scripts": ["hot-reload.js"] 7 | }, 8 | "browser_action": {}, 9 | "content_scripts": [{ 10 | "matches": ["https://github.com/*"], 11 | "js": ["content.bundle.js"] 12 | }], 13 | "icons": { 14 | "128": "cherry-128.png" 15 | }, 16 | "permissions": [ 17 | "storage" 18 | ], 19 | "content_security_policy": "script-src 'self' 'sha256-GgRxrVOKNdB4LrRsVPDSbzvfdV4UqglmviH9GoBJ5jk='; object-src 'self'" 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/windowLocation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import gitHubInjection from "github-injection"; 3 | 4 | export const useWindowLocation = () => { 5 | const [location, setLocation] = useState(window.location.href); 6 | useEffect(() => { 7 | window.addEventListener("popstate", () => { 8 | setLocation(window.location.href); 9 | }); 10 | 11 | gitHubInjection(() => { 12 | if (window.location.href !== location) { 13 | setLocation(window.location.href); 14 | } 15 | }); 16 | }, []); 17 | 18 | return location; 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/SliceBtn.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from "react"; 2 | 3 | export const SliceBtn = ({ emitter}) => { 4 | const [slices, setSlices] = useState([]); 5 | useEffect(()=>{ 6 | emitter.on('slices-updated', (slices)=>{ 7 | setSlices(slices); 8 | setTimeout(()=>document.body.click(),2); 9 | }) 10 | }, []); 11 | 12 | return slices.length ? :
16 | } -------------------------------------------------------------------------------- /src/hooks/auth.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useStorage } from "./storage"; 3 | 4 | export const useAuth = () => { 5 | const [auth, setAuth, loaded] = useStorage("auth", { 6 | authMethod: "password" 7 | }); 8 | 9 | const valid = validateAuth(auth); 10 | 11 | return [auth, setAuth, loaded, valid]; 12 | }; 13 | 14 | export const validateAuth = ({ authMethod, token, username, password }) => { 15 | if (authMethod === "password") { 16 | return username && password; 17 | } else if (authMethod === "token") { 18 | return token; 19 | } else { 20 | return false; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/Root.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Switch } from "react-router-dom"; 2 | import React, {useEffect, useState, useContext} from "react"; 3 | import { useSlices } from "../hooks/slices"; 4 | import { useGlobalState, InitStatus } from "../context/GlobalState"; 5 | import { useStorage } from '../hooks/storage'; 6 | import { Routes } from "./Routes"; 7 | import Modal from "react-awesome-modal"; 8 | export const Root = () => { 9 | const [modalVisible, setModalVisible] = useGlobalState("modalVisible"); 10 | return (setModalVisible(false)}>
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | export const SettingsBtn = () => { 3 | return
// 4 | } 5 | 6 | const GearIcon = ()=> { 7 | return 8 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "jsdom", 3 | rootDir: ".", 4 | verbose: true, 5 | globals: { 6 | "ts-jest": { 7 | tsConfig: "tsconfig.json", 8 | diagnostics: { 9 | warnOnly: true 10 | } 11 | } 12 | }, 13 | transform: { 14 | "^.+\\.tsx?$": "ts-jest" 15 | }, 16 | testRegex: "(/__tests__/.*|(\\.|/)(spec))\\.(jsx?|tsx?)$", 17 | moduleFileExtensions: ["ts", "js", "tsx", "jsx"], 18 | collectCoverage: true, 19 | coverageReporters: ["lcov", "json"], 20 | coveragePathIgnorePatterns: ["/node_modules/", "/test/", "/dist/"], 21 | modulePathIgnorePatterns: ["/cloned/"], 22 | testEnvironment: "node" 23 | }; 24 | -------------------------------------------------------------------------------- /src/service/Storage.ts: -------------------------------------------------------------------------------- 1 | export class StorageService { 2 | static loadFromStorage = async () => { 3 | const saved = await localStorage.getItem("repos"); 4 | if (saved) { 5 | return JSON.parse(saved); 6 | } 7 | return {}; 8 | }; 9 | static saveToStorage = async repos => { 10 | await localStorage.setItem("repos", JSON.stringify(repos)); 11 | }; 12 | 13 | static addRepo = async ({ 14 | url, 15 | login, 16 | password 17 | }: { 18 | url: string; 19 | login: string; 20 | password: string; 21 | }) => { 22 | const load = await StorageService.loadFromStorage(); 23 | load[url] = { url, login, password }; 24 | StorageService.saveToStorage(load); 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/CloseBtn.tsx: -------------------------------------------------------------------------------- 1 | import { useGlobalState } from "../context/GlobalState"; 2 | import React from "react"; 3 | import { useSlices } from "../hooks/slices"; 4 | 5 | export const CloseBtn = ({label, disabled, reset}: {label: string, disabled?: boolean, reset?: boolean}) => { 6 | const [modalVisible, setModalVisible] = useGlobalState("modalVisible"); 7 | const { slices, setSlices } = useSlices(); 8 | 9 | return 22 | } -------------------------------------------------------------------------------- /src/context/GlobalStore.tsx: -------------------------------------------------------------------------------- 1 | import { createStore } from 'react-hooks-global-state'; 2 | import { clone } from "lodash" 3 | const reducer = (state, action) => { 4 | switch(action.type){ 5 | case "add-message": 6 | const messages = clone(state.messages); 7 | messages.push(action.value); 8 | return {...state, messages } 9 | case "clear-messages": 10 | return {...state, messages:[] } 11 | default: 12 | return {...state} 13 | } 14 | } 15 | 16 | const { GlobalStateProvider, dispatch, useGlobalState } = createStore(reducer, { messages: []}); 17 | 18 | const GlobalStoreProvider = GlobalStateProvider; 19 | const useGlobalStore = useGlobalState; 20 | 21 | export { GlobalStoreProvider, useGlobalStore, dispatch}; -------------------------------------------------------------------------------- /src/components/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { useGlobalState } from "../context/GlobalState"; 3 | import { Files } from "../components/Files"; 4 | import { Review } from "../components/Review"; 5 | import { Login } from "../components/Login"; 6 | import { Execute } from "../components/Execute"; 7 | export const Routes = () => { 8 | const [route] = useGlobalState("route"); 9 | 10 | switch (route) { 11 | case "/files": 12 | return ; 13 | case "/review": 14 | return ; 15 | case "/login": 16 | return ; 17 | case "/execute": 18 | return ; 19 | case "/error": 20 | return
Initialize error
21 | default: 22 | return
404 {route}
; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/hooks/slices.ts: -------------------------------------------------------------------------------- 1 | import { useGlobalState } from "../context/GlobalState"; 2 | import { clone } from "lodash"; 3 | import { useState, useEffect } from "react"; 4 | 5 | export const useSlices = ()=>{ 6 | const [slice, addSlice] = useState(); 7 | const [slices, setSlices] = useGlobalState("slices"); 8 | useEffect(()=>{ 9 | if(slice && slices.indexOf(slice)<0){ 10 | const cloned = clone(slices); 11 | cloned.push(slice); 12 | setSlices(cloned); 13 | addSlice(undefined); 14 | } 15 | }, [slice]) 16 | 17 | 18 | const removeSlice = (path) => { 19 | const filtered = slices.filter(slice=>path!==slice); 20 | setSlices(filtered); 21 | } 22 | return {slices, addSlice, removeSlice, setSlices}; 23 | } -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "npmPublish": false, 3 | "verifyConditions": ["semantic-release-chrome", "@semantic-release/github"], 4 | "prepare": [ 5 | { 6 | "path": "semantic-release-chrome", 7 | "asset": "extension.zip" 8 | } 9 | ], 10 | "plugins": [ 11 | ["@semantic-release/commit-analyzer", { 12 | "preset": "angular", 13 | "releaseRules": [ 14 | {"type": "update", "release": "patch"} 15 | ], 16 | "parserOpts": { 17 | "noteKeywords": ["BREAKING CHANGE", "BREAKING CHANGES"] 18 | } 19 | }] 20 | ], 21 | "publish": [ 22 | { 23 | "path": "semantic-release-chrome", 24 | "asset": "extension.zip", 25 | "extensionId": "fiaignmlhapejpdfbephokpkjnmnaapo" 26 | }, 27 | { 28 | "path": "@semantic-release/github", 29 | "assets": [ 30 | { 31 | "path": "extension.zip" 32 | } 33 | ] 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /src/context/GlobalState.tsx: -------------------------------------------------------------------------------- 1 | import { createGlobalState, createStore } from 'react-hooks-global-state'; 2 | import { PullsGetResponse } from "@octokit/rest"; 3 | import { ProcessStatus } from '../constants'; 4 | 5 | export enum InitStatus { 6 | NotReady = "NotReady", 7 | NoAuth = "NoAuth", 8 | Idle = "Idle", 9 | Working = "Working", 10 | Done = "Done", 11 | Failed = "Failed" 12 | } 13 | 14 | export enum InitError { 15 | None = "None", 16 | NotAuth = "NotAuth", 17 | Unknown = "Unknown" 18 | } 19 | 20 | const { GlobalStateProvider, useGlobalState } = createGlobalState({ 21 | modalVisible: false, 22 | initStatus: ProcessStatus.Idle, 23 | initError: undefined as any, 24 | slices: [] as string[], 25 | messages: [] as string[], 26 | route: "/login", 27 | currentPr: PullsGetResponse, 28 | currentPrError: undefined as any, 29 | sliceInfo: undefined as any 30 | }); 31 | export {GlobalStateProvider, useGlobalState}; 32 | 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "moduleResolution": "node", 5 | "target": "es2015", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "module": "commonjs", 9 | "lib": ["es2015", "es2016", "es2017", "dom"], 10 | "noImplicitAny": false, 11 | "noImplicitThis": false, 12 | "sourceMap": true, 13 | "declaration": true, 14 | "emitDecoratorMetadata": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "strictNullChecks": true, 17 | "suppressImplicitAnyIndexErrors": true, 18 | "downlevelIteration": true, 19 | "importHelpers": true, 20 | "noEmitOnError": true, 21 | "strict": true, 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": false, 24 | "noImplicitReturns": true, 25 | "noFallthroughCasesInSwitch": false, 26 | "experimentalDecorators": true, 27 | "outDir": "dist/lib", 28 | "skipLibCheck": true 29 | }, 30 | "include": ["src"], 31 | "exclude": ["node_modules"] 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Shlomo Kraus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/hooks/cherry.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useContext } from "react"; 2 | import { CherryContext } from "../context/Cherry"; 3 | import { dispatch } from "../context/GlobalStore"; 4 | import { ProcessStatus } from "../constants"; 5 | 6 | export const useSlice = () => { 7 | const cherry = useContext(CherryContext); 8 | const [status, setStatus] = useState(ProcessStatus.Idle); 9 | const [error, setError] = useState(); 10 | 11 | const slice = async ({paths, sourceBranch, targetBranch, baseBranch, createPr, prTitle, prBody, message}) =>{ 12 | try { 13 | dispatch({type: "clear-messages"}) 14 | setStatus(ProcessStatus.Working); 15 | await cherry.slice({ 16 | paths, 17 | sourceBranch, 18 | targetBranch, 19 | baseBranch, 20 | message, 21 | createPr, 22 | prBody, 23 | prTitle 24 | }); 25 | setStatus(ProcessStatus.Done); 26 | } catch(ex){ 27 | console.log("err", ex); 28 | setError(ex.message); 29 | setStatus(ProcessStatus.Failed); 30 | } 31 | } 32 | 33 | return { slice, status, error } 34 | 35 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | config/local*json 63 | .DS_Store 64 | dist 65 | generated 66 | release.sh 67 | test/output/*.png -------------------------------------------------------------------------------- /src/hooks/login.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useContext, useCallback } from "react"; 2 | import { CherryContext } from "../context/Cherry"; 3 | import { ProcessStatus } from "../constants"; 4 | import { useGlobalState } from "../context/GlobalState"; 5 | import { useAuth } from "../hooks/auth"; 6 | 7 | export const useLogin = () => { 8 | const [status, setStatus] = useGlobalState("initStatus"); 9 | const [error, setError] = useGlobalState("initError"); 10 | const cherry = useContext(CherryContext); 11 | const [auth, setAuth] = useAuth(); 12 | 13 | const login = async (params: { 14 | username?: string; 15 | password?: string; 16 | token?: string; 17 | authMethod: string; 18 | save?: boolean; 19 | }) => { 20 | if (!cherry) { 21 | throw Error("Missing Cherry service in context"); 22 | } 23 | setStatus(ProcessStatus.Working); 24 | try { 25 | await cherry.init(params); 26 | setStatus(ProcessStatus.Done); 27 | if (params.save) { 28 | setAuth({ 29 | ...auth, 30 | username: params.username, 31 | password: params.password, 32 | token: params.token 33 | }); 34 | } 35 | return true; 36 | } catch (ex) { 37 | console.log("Init error", ex.message); 38 | setStatus(ProcessStatus.Failed); 39 | setError(ex.message); 40 | return false; 41 | } 42 | }; 43 | 44 | return { login: useCallback(login, [cherry]), status, error }; 45 | }; 46 | -------------------------------------------------------------------------------- /assets/hot-reload.js: -------------------------------------------------------------------------------- 1 | const filesInDirectory = dir => new Promise (resolve => 2 | 3 | dir.createReader ().readEntries (entries => 4 | 5 | Promise.all (entries.filter (e => e.name[0] !== '.').map (e => 6 | 7 | e.isDirectory 8 | ? filesInDirectory (e) 9 | : new Promise (resolve => e.file (resolve)) 10 | )) 11 | .then (files => [].concat (...files)) 12 | .then (resolve) 13 | ) 14 | ) 15 | 16 | const timestampForFilesInDirectory = dir => 17 | filesInDirectory (dir).then (files => 18 | files.map (f => f.name + f.lastModifiedDate).join ()) 19 | 20 | const reload = () => { 21 | 22 | chrome.tabs.query ({ active: true, currentWindow: true }, tabs => { // NB: see https://github.com/xpl/crx-hotreload/issues/5 23 | 24 | if (tabs[0]) { chrome.tabs.reload (tabs[0].id) } 25 | 26 | chrome.runtime.reload () 27 | }) 28 | } 29 | 30 | const watchChanges = (dir, lastTimestamp) => { 31 | 32 | timestampForFilesInDirectory (dir).then (timestamp => { 33 | 34 | if (!lastTimestamp || (lastTimestamp === timestamp)) { 35 | 36 | setTimeout (() => watchChanges (dir, timestamp), 1000) // retry after 1s 37 | 38 | } else { 39 | 40 | reload () 41 | } 42 | }) 43 | 44 | } 45 | 46 | chrome.management.getSelf (self => { 47 | 48 | if (self.installType === 'development') { 49 | 50 | chrome.runtime.getPackageDirectoryEntry (dir => watchChanges (dir)) 51 | } 52 | }) -------------------------------------------------------------------------------- /src/context/PullRequest.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState, useEffect, useContext } from "react"; 3 | import { CherryContext } from "./Cherry"; 4 | import { CherryPieService} from "../service/CherryPie"; 5 | import { useWindowLocation } from "../hooks/windowLocation"; 6 | 7 | const useLoadCurrentPr = (number?) => { 8 | const cherry = useContext(CherryContext); 9 | const [pr, setPr] = useState(); 10 | const [error, setError] = useState(); 11 | const location = useWindowLocation(); 12 | 13 | useEffect(()=>{ 14 | if(!cherry){ 15 | return 16 | } 17 | if(!number) { 18 | 19 | const config = CherryPieService.parsePrUrl(location); 20 | if(config===undefined){ 21 | return 22 | } else { 23 | number = config.number; 24 | } 25 | } 26 | 27 | if(pr&&pr.number===number){ 28 | return 29 | } 30 | 31 | if(cherry.isInit){ 32 | cherry.client().loadPr(number).then(setPr).catch(setError); 33 | } else { 34 | console.log("Cherry not initialize"); 35 | } 36 | }, [cherry, cherry && cherry.isInit, location]); 37 | 38 | return { pr, error } 39 | } 40 | 41 | export const PrContext = React.createContext({pr:undefined, error: undefined}); 42 | 43 | export const PrProvider = props => { 44 | const {pr, error} = useLoadCurrentPr(props.number); 45 | return {props.children} 46 | } -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | Asset 3 -------------------------------------------------------------------------------- /src/context/Cherry.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from "react"; 2 | import { CherryPieService } from "../service/CherryPie"; 3 | import { GithubService } from "../service/Github"; 4 | import { useGlobalStore, dispatch} from "./GlobalStore"; 5 | import { useWindowLocation } from "../hooks/windowLocation"; 6 | export const CherryContext = React.createContext(); 7 | 8 | /** 9 | * Parse url and initialize github service and cherry. 10 | * Execute on every location change but only initialize if this is a pull request page. 11 | */ 12 | const useCherryClient = (config?: { owner: string, repo: string, number: number}) => { 13 | const [cherry, setCherry] = useState(); 14 | const location = useWindowLocation(); 15 | 16 | // Construct client 17 | useEffect( 18 | () => { 19 | if(!config) { 20 | config = CherryPieService.parsePrUrl(location); 21 | if (!config) { 22 | setCherry(undefined); 23 | } 24 | } 25 | 26 | const github = new GithubService({ ...config, refPrefix: "heads" } as any); 27 | const cherryClient = new CherryPieService(github, { 28 | print: (message) => dispatch({type: "add-message", value: message}) 29 | }); 30 | 31 | setCherry(cherryClient); 32 | }, 33 | [location] 34 | ); 35 | 36 | return cherry; 37 | }; 38 | 39 | 40 | 41 | export const CherryProvider = props => { 42 | const config = props.config; 43 | const client = useCherryClient(config); 44 | return ( 45 | 46 | {props.children} 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /test/service/CherryPie.spec.ts: -------------------------------------------------------------------------------- 1 | import { CherryPieService} from "../../src/service/CherryPie"; 2 | import { GithubService } from "../../src/service/Github"; 3 | import { MessagesService } from "../../src/service/Messages"; 4 | import config from "config"; 5 | import shortid from "shortid"; 6 | 7 | describe("CherryPie Service - Integration Tests", () => { 8 | 9 | let cherry: CherryPieService; 10 | let github: GithubService; 11 | let messageService: MessagesService = { 12 | print: console.log 13 | } 14 | 15 | beforeAll(async ()=>{ 16 | const { owner, repo, username, password, refPrefix } = config.get("github") 17 | github = new GithubService({owner, repo, refPrefix}); 18 | await github.init({username, password}); 19 | cherry = new CherryPieService(github, messageService); 20 | }); 21 | 22 | afterEach(()=>{ 23 | jest.resetAllMocks(); 24 | }); 25 | 26 | it("slice()", async () => { 27 | const { paths, sourceBranch, targetBranch, baseBranch } = config.get("test.slice"); 28 | const result = await cherry.slice({paths, sourceBranch, targetBranch, baseBranch}); 29 | 30 | const verifyTarget = await github.getBranch(targetBranch); 31 | 32 | expect(verifyTarget.commit.sha).toEqual(result.object.sha); 33 | 34 | const verifySource = await github.getBranch(sourceBranch); 35 | const target = await github.getFileContent(paths[0], verifyTarget.commit.sha); 36 | const source = await github.getFileContent(paths[0], verifySource.commit.sha); 37 | expect(target).toEqual(source); 38 | 39 | // Cleanup 40 | await github.deleteBranch(targetBranch); 41 | }, 20000) 42 | 43 | }) -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | webpack 4 quickstart 7 | 8 | 9 | 10 | 11 | 53 | 54 | 55 | 56 | 64 | Test Page 65 | 66 | 67 |
68 |
69 | Button: 70 |
71 |
72 |
73 |
74 | 75 | config/production.json 76 |
77 |
78 |
79 |
80 |
81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Asset 3 6 | 7 | background 8 | 9 | 10 | 11 | Layer 1 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/hooks/storage.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export const useStorage = (key, defaultValue?) => { 4 | const [loaded, setLoaded] = useState(false); 5 | const [storageItem, updateStorage] = useState(defaultValue); 6 | 7 | useEffect( 8 | () => { 9 | /** 10 | * Fallback for localstorage 11 | */ 12 | if (!chrome.storage) { 13 | window.addEventListener( 14 | "storage", 15 | e => { 16 | console.log("Event", e); 17 | const val = e.detail.newval; 18 | if(val!==storageItem){ 19 | updateStorage(value); 20 | } 21 | }, 22 | false 23 | ); 24 | 25 | let value = localStorage.getItem(key); 26 | if(value){ 27 | value = JSON.parse(value) 28 | } else { 29 | value = defaultValue; 30 | } 31 | 32 | updateStorage(value); 33 | setLoaded(true); 34 | return 35 | } 36 | 37 | chrome.storage.onChanged.addListener(function(changes, namespace) { 38 | if (!(key in changes)) { 39 | return; 40 | } 41 | 42 | let updated = changes[key]; 43 | 44 | if (updated) { 45 | updateStorage(updated.newValue); 46 | } else { 47 | updateStorage(updated); 48 | } 49 | }); 50 | 51 | chrome.storage.sync.get([key], result => { 52 | let value = result[key]; 53 | if (!value) { 54 | chrome.storage.sync.set({ [key]: defaultValue }); 55 | } else { 56 | updateStorage(value); 57 | } 58 | 59 | setLoaded(true); 60 | }); 61 | 62 | return () => { 63 | return 64 | }; 65 | }, 66 | [key] 67 | ); 68 | 69 | const updateStorageItem = async value => { 70 | if (!chrome.storage) { 71 | return localStorage.setItem(key, JSON.stringify(value)); 72 | } 73 | 74 | return chrome.storage.sync.set({ [key]: value }); 75 | }; 76 | 77 | return [storageItem, updateStorageItem, loaded]; 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from "react"; 2 | import { CherryProvider, CherryContext } from "../context/Cherry"; 3 | import { GlobalStateProvider, useGlobalState } from "../context/GlobalState"; 4 | import { Root } from "./Root"; 5 | import { GlobalStoreProvider } from "../context/GlobalStore"; 6 | import { useSlices } from "../hooks/slices"; 7 | import { useAuth } from "../hooks/auth"; 8 | import { useLogin } from "../hooks/login"; 9 | import { useCurrentPr } from "../hooks/currentPr"; 10 | import { PrProvider } from "../context/PullRequest"; 11 | /** 12 | * Listen to events coming in from external components 13 | */ 14 | export const useEmitter = emitter => { 15 | const { addSlice, slices } = useSlices(); 16 | const [modalVisible, setModalVisible] = useGlobalState("modalVisible"); 17 | 18 | useEffect( 19 | () => { 20 | emitter.emit("slices-updated", slices); 21 | }, 22 | [slices] 23 | ); 24 | 25 | useEffect(() => { 26 | emitter.on("file-slice", filename => { 27 | addSlice(filename); 28 | }); 29 | 30 | emitter.on("show-modal", filename => { 31 | setModalVisible(true); 32 | }); 33 | }, []); 34 | }; 35 | 36 | /** 37 | * Initialize the app by authenticating if payload exists 38 | */ 39 | export const useInit = () => { 40 | const cherry = useContext(CherryContext); 41 | const [auth, setAuth, loaded, valid] = useAuth(); 42 | const { login } = useLogin(); 43 | useEffect( 44 | () => { 45 | if (loaded && cherry && valid) { 46 | login(auth); 47 | } 48 | }, 49 | [cherry, loaded] 50 | ); 51 | }; 52 | 53 | export const Container = props => { 54 | return ( 55 | 56 | 57 | 58 | {props.children} 59 | 60 | 61 | 62 | ); 63 | }; 64 | 65 | export function AppContent({ emitter }) { 66 | useEmitter(emitter); 67 | useInit(); 68 | useCurrentPr(); 69 | return ; 70 | } 71 | 72 | export const App = props => { 73 | return ( 74 | 75 | 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /test/storybook/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { Button } from "@storybook/react/demo"; 4 | import { Login } from "../../src/components/Login"; 5 | import { Execute } from "../../src/components/Execute"; 6 | import { Files } from "../../src/components/Files"; 7 | import { Review } from "../../src/components/Review"; 8 | import { CherryProvider, CherryContext } from "../../src/context/Cherry"; 9 | import { 10 | GlobalStateProvider, 11 | useGlobalState 12 | } from "../../src/context/GlobalState"; 13 | import { GlobalStoreProvider } from "../../src/context/GlobalStore"; 14 | import { PrContext } from "../../src/context/PullRequest"; 15 | 16 | import { GetPullsResponseMock } from "../mocks/pr"; 17 | 18 | const AppWrapper = props => { 19 | return ( 20 |
28 |
32 | 33 | 34 | 35 | 36 | {props.children} 37 | 38 | 39 | 40 | 41 |
42 |
43 | ); 44 | }; 45 | storiesOf("Authentication Page", module).add("Default", () => ( 46 | 47 | 48 | 49 | )); 50 | 51 | storiesOf("Files Page", module).add("Default", () => ( 52 | 53 | 54 | 55 | )); 56 | 57 | storiesOf("Review Page", module).add("Default", () => ( 58 | 59 | 60 | 61 | )); 62 | 63 | storiesOf("Execute Page", module).add("Default", () => ( 64 | 65 | 66 | 67 | )).add("With data", () => { 68 | return 69 | 70 | 71 | }); 72 | 73 | 74 | const ExecuteTest = (props) => { 75 | 76 | return <> 77 | 78 | 79 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin') 5 | 6 | module.exports = { 7 | mode: "development", 8 | target: 'web', 9 | devtool: "source-map", 10 | context: path.resolve(__dirname, 'src'), 11 | entry: { 12 | popup: path.resolve('./src/extension/Popup.tsx'), 13 | // background: buildEntryPoint(path.resolve('./src/extension/Background.ts')), 14 | content: path.resolve('./src/extension/Content.tsx') 15 | }, 16 | output: { 17 | path: path.join(__dirname, "dist"), 18 | filename: "[name].bundle.js", 19 | publicPath: '/' 20 | }, 21 | devServer: { 22 | contentBase: path.join(__dirname, 'dist'), 23 | openPage: '/content.html', 24 | headers: { 'Access-Control-Allow-Origin': '*' }, 25 | https: false, 26 | disableHostCheck: true 27 | }, 28 | optimization: { 29 | minimize: false 30 | }, 31 | resolve: { 32 | extensions: [".ts", ".tsx", ".js", ".json"], 33 | }, 34 | module: { 35 | rules: [{ 36 | test: /\.tsx?$/, 37 | loader: 'babel-loader', 38 | }, 39 | { 40 | test: /\.js$/, 41 | use: ["source-map-loader"], 42 | enforce: "pre" 43 | }, 44 | { 45 | test: /\.png$/, 46 | loader: "url-loader?limit=100000" 47 | }, 48 | { 49 | test: /\.jpg$/, 50 | loader: "file-loader" 51 | }, 52 | { 53 | test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, 54 | loader: 'url-loader?limit=10000&mimetype=application/font-woff' 55 | }, 56 | { 57 | test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, 58 | loader: 'url-loader?limit=10000&mimetype=application/octet-stream' 59 | }, 60 | { 61 | test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, 62 | loader: 'file-loader' 63 | }, 64 | { 65 | test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, 66 | loader: 'url-loader?limit=10000&mimetype=image/svg+xml' 67 | } 68 | ] 69 | }, 70 | plugins: [ 71 | new HtmlWebpackPlugin({ 72 | template: path.resolve(__dirname, 'assets/index.html'), 73 | filename: "popup.html", 74 | chunks: ["popup"] 75 | }), 76 | new HtmlWebpackPlugin({ 77 | template: path.resolve(__dirname, 'assets/index.html'), 78 | filename: "content.html", 79 | chunks: ["content"] 80 | }), 81 | new CopyWebpackPlugin([{ 82 | from: path.resolve(__dirname, 'assets/manifest.json') 83 | }, 84 | { 85 | from: path.resolve(__dirname, 'assets/hot-reload.js') 86 | }, 87 | { 88 | from: path.resolve(__dirname, 'assets/cherry-128.png') 89 | }]) 90 | ] 91 | } 92 | -------------------------------------------------------------------------------- /src/components/Files.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext, useState } from "react"; 2 | import { useSlices } from "../hooks/slices"; 3 | import { useGlobalState } from "../context/GlobalState"; 4 | import { CherryContext } from "../context/Cherry"; 5 | import { useCurrentPr } from "../hooks/currentPr"; 6 | import { CloseBtn } from "./CloseBtn"; 7 | 8 | export const Files = () => { 9 | const cherry = useContext(CherryContext); 10 | const { slices, removeSlice } = useSlices(); 11 | const [route, setRoute] = useGlobalState("route"); 12 | 13 | if(!cherry) { 14 | return
15 | } 16 | const {pr} = useCurrentPr(); 17 | if (!pr) { 18 | return
Pull request no loaded yet
; 19 | } 20 | 21 | return ( 22 |
23 |
24 |

REVIEW SELECTED FILES

25 |
26 | 27 |
28 |

29 | Review {slices.length} files selected from {pr.head.ref} 30 |

31 |
32 |
36 | {slices && 37 | slices.map((filename, index) => ( 38 |
39 |
40 |
41 | {filename} 42 |
43 |
removeSlice(filename)}>
44 |
45 | ))} 46 |
47 | 48 |
49 | 50 | 51 | 60 |
61 |
62 | ); 63 | }; 64 | 65 | 66 | const FileIcon = () =>{ 67 | return 68 | } 69 | 70 | const TrashIcon = () => { 71 | return 72 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cherrypie", 3 | "version": "1.0.0", 4 | "description": "Beyond cherry picking", 5 | "main": "index.js", 6 | "author": "Shlomo Kraus", 7 | "repository": "https://github.com/shlomokraus/cherrypie.git", 8 | "license": "MIT", 9 | "scripts": { 10 | "watch": "webpack --watch", 11 | "dev": "webpack-dev-server --mode development --open", 12 | "prebuild": "rimraf ./dist", 13 | "build": "webpack --mode production", 14 | "release": "semantic-release", 15 | "storybook": "start-storybook -p 9001 -c .storybook", 16 | "test": "jest --config ./jest.config.js --verbose", 17 | "live": "jest --runInBand --config ./jest.chrome.js", 18 | "test:watch": "jest --config ./jest.config.js --watch --runInBand --verbose --coverage=false" 19 | }, 20 | "dependencies": { 21 | "@babel/core": "^7.2.2", 22 | "@babel/node": "^7.2.2", 23 | "@babel/plugin-proposal-class-properties": "^7.2.3", 24 | "@babel/plugin-proposal-decorators": "^7.2.3", 25 | "@babel/plugin-transform-classes": "^7.2.2", 26 | "@babel/preset-env": "^7.2.3", 27 | "@babel/preset-react": "^7.0.0", 28 | "@babel/preset-typescript": "^7.1.0", 29 | "@emotion/babel-preset-css-prop": "^10.0.5", 30 | "@emotion/core": "^10.0.5", 31 | "@emotion/styled": "^10.0.5", 32 | "@githubprimer/octicons-react": "^8.2.0", 33 | "@octokit/rest": "^16.3.0", 34 | "@semantic-release/github": "^5.2.8", 35 | "@storybook/react": "^4.1.6", 36 | "@types/jest": "^23.3.10", 37 | "@types/jest-environment-puppeteer": "^2.2.1", 38 | "@types/node": "^10.12.18", 39 | "@types/puppeteer": "^1.11.1", 40 | "babel-loader": "^8.0.4", 41 | "babel-plugin-emotion": "^10.0.5", 42 | "config": "^3.0.1", 43 | "copy-webpack-plugin": "^4.6.0", 44 | "details-dialog-element": "^1.4.1", 45 | "formik": "^1.4.2", 46 | "git-url-parse": "^11.1.1", 47 | "github-injection": "^1.0.1", 48 | "html-webpack-plugin": "^3.2.0", 49 | "inversify": "^5.0.1", 50 | "jest": "^23.6.0", 51 | "jest-cli": "^23.6.0", 52 | "jest-environment-puppeteer": "^3.8.0", 53 | "jest-puppeteer": "^3.8.0", 54 | "lodash": "^4.17.11", 55 | "react": "16.7.0-alpha.2", 56 | "react-awesome-modal": "^2.0.5", 57 | "react-dom": "16.7.0-alpha.2", 58 | "react-hooks-global-state": "^0.4.1", 59 | "react-hot-loader": "^4.6.3", 60 | "react-router": "^4.3.1", 61 | "react-router-dom": "^4.3.1", 62 | "reflect-metadata": "^0.1.12", 63 | "rimraf": "^2.6.2", 64 | "semantic-release": "^15.13.2", 65 | "semantic-release-chrome": "^1.1.0", 66 | "shortid": "^2.2.14", 67 | "source-map-loader": "^0.2.4", 68 | "tiny-emitter": "^2.0.2", 69 | "ts-import-plugin": "^1.5.5", 70 | "ts-jest": "^23.10.5", 71 | "ts-loader": "^5.3.2", 72 | "tslib": "^1.9.3", 73 | "typescript": "^3.2.2", 74 | "url-loader": "^1.1.2", 75 | "url-parse": "^1.4.4", 76 | "webpack": "^4.28.2", 77 | "webpack-cli": "^3.1.2", 78 | "webpack-dev-server": "^3.1.14", 79 | "webpack-livereload-plugin": "^2.2.0" 80 | }, 81 | "devDependencies": { 82 | "puppeteer": "^1.11.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /test/puppeteer/index.live.ts: -------------------------------------------------------------------------------- 1 | import config from "config"; 2 | 3 | const OUTPUT_DIR = "./test/puppeteer" 4 | 5 | const SELECTED_FILENAME = "file2.js"; 6 | const MAIN_SLICE_BTN_WRAPPER = ".cherry-pie-toolbar"; 7 | const MAIN_SLICE_BTN = `${MAIN_SLICE_BTN_WRAPPER} button`; 8 | const MAIN_SLICE_BTN_COUNT_LABEL = `${MAIN_SLICE_BTN} span`; 9 | const SLICE_BTN = ".cherry-action a"; 10 | const LOGIN_PAGE = ".cherry-login-page"; 11 | const USERNAME_INPUT = `${LOGIN_PAGE} [name=username]`; 12 | const PASSWORD_INPUT = `${LOGIN_PAGE} [name=password]`; 13 | const LOGIN_SUBMIT = `${LOGIN_PAGE} [type=submit]`; 14 | const FILES_PAGE = ".cherry-files-page"; 15 | const FILE_ITEM = ".cherry-file-list-item"; 16 | const FILE_ITEM_FILENAME = `${FILE_ITEM} .file-name`; 17 | const FILE_ITEM_REMOVE = `${FILE_ITEM} .remove-file`; 18 | describe('Cherry Pie Live', () => { 19 | beforeAll(async () => { 20 | return page.goto(config.get("test.live.url")); 21 | }); 22 | 23 | 24 | it('should load the extension', async () => { 25 | const btn = await page.$(MAIN_SLICE_BTN_WRAPPER); 26 | expect(btn).toBeDefined() 27 | }); 28 | 29 | 30 | it("should have the main button hidden", async () => { 31 | const btn = await page.$(MAIN_SLICE_BTN); 32 | expect(btn).toEqual(null) 33 | }) 34 | 35 | it('should have at least one visible Slice btn', async () => { 36 | const btn = await page.$(SLICE_BTN); 37 | const text = await page.evaluate(element => element.textContent, btn); 38 | expect(text).toEqual("Slice") 39 | }); 40 | 41 | it('clicking the slice btn will increment the slice count and show main button', async () => { 42 | await page.click(SLICE_BTN); 43 | const btn = await page.waitForSelector(MAIN_SLICE_BTN_COUNT_LABEL); 44 | const text = await page.evaluate(element => element.textContent, btn); 45 | expect(text).toEqual("1") 46 | 47 | }); 48 | 49 | it('clicking the main button will show login box', async () => { 50 | await page.click(MAIN_SLICE_BTN); 51 | await page.waitForSelector(LOGIN_PAGE); 52 | await page.screenshot({path: OUTPUT_DIR+"/login.png"}); 53 | }); 54 | 55 | it('login with username and password should direct to files page', async () => { 56 | await page.type(USERNAME_INPUT, config.get("github.username")); 57 | await page.type(PASSWORD_INPUT, config.get("github.password")); 58 | await page.click(LOGIN_SUBMIT); 59 | await page.waitFor(2000); 60 | await page.screenshot({path:OUTPUT_DIR+"login-submit.png"}); 61 | 62 | await page.waitForSelector(FILES_PAGE); 63 | },10000); 64 | 65 | it('should have our selected file listed', async () => { 66 | await page.screenshot({path: OUTPUT_DIR+"files.png"}); 67 | 68 | const item = await page.$(FILE_ITEM_FILENAME); 69 | const text = await page.evaluate(element => element.textContent, item); 70 | 71 | expect(text).toEqual(SELECTED_FILENAME); 72 | }) 73 | 74 | it('clicking trash icon should remove the file', async () => { 75 | await page.screenshot({path: OUTPUT_DIR+"files.png"}); 76 | await page.click(FILE_ITEM_REMOVE); 77 | await page.waitFor(100); 78 | const item = await page.$(FILE_ITEM_FILENAME); 79 | await page.screenshot({path: OUTPUT_DIR+"files-remove.png"}); 80 | 81 | expect(item).toEqual(null); 82 | }) 83 | }); -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build-test: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:11.7.0-browsers 11 | 12 | working_directory: ~/repo 13 | 14 | steps: 15 | - checkout 16 | 17 | # Install chrome 18 | - run: 19 | name: "Update packages" 20 | command: | 21 | sudo apt-get update 22 | sudo apt-get install -y wget --no-install-recommends 23 | - run: 24 | name: "Install Chrome" 25 | command: | 26 | sudo wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - 27 | sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' 28 | sudo apt-get update 29 | sudo apt-get install -y google-chrome-unstable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst ttf-freefont --no-install-recommends 30 | - run: 31 | name: Install Headless Chrome dep. 32 | command: | 33 | sudo apt-get -y install gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils 34 | 35 | # Download and cache dependencies 36 | - restore_cache: 37 | keys: 38 | - v1-dependencies-{{ checksum "package.json" }} 39 | # fallback to using the latest cache if no exact match is found 40 | - v1-dependencies- 41 | 42 | - run: yarn install 43 | 44 | - save_cache: 45 | paths: 46 | - node_modules 47 | key: v1-dependencies-{{ checksum "package.json" }} 48 | 49 | - run: 50 | name: Building 51 | command: | 52 | yarn run build 53 | 54 | - run: 55 | name: Integration tests 56 | command: | 57 | yarn run test 58 | 59 | - run: 60 | name: Live tests 61 | command: | 62 | yarn run live 63 | - store_artifacts: 64 | path: ./test/puppeteer 65 | destination: live-snapshots 66 | 67 | build-release: 68 | docker: 69 | # specify the version you desire here 70 | - image: circleci/node:9.10.0-browsers 71 | 72 | working_directory: ~/repo 73 | 74 | steps: 75 | - checkout 76 | - restore_cache: 77 | keys: 78 | - v1-dependencies-{{ checksum "package.json" }} 79 | # fallback to using the latest cache if no exact match is found 80 | - v1-dependencies- 81 | 82 | - run: yarn install 83 | 84 | - save_cache: 85 | paths: 86 | - node_modules 87 | key: v1-dependencies-{{ checksum "package.json" }} 88 | 89 | - run: 90 | name: Building 91 | command: | 92 | yarn run build 93 | - run: 94 | name: Release 95 | command: | 96 | yarn semantic-release 97 | 98 | 99 | workflows: 100 | version: 2 101 | branch: 102 | jobs: 103 | - build-test: 104 | filters: 105 | branches: 106 | ignore: 107 | - master 108 | 109 | master: 110 | jobs: 111 | - build-release: 112 | filters: 113 | branches: 114 | only: 115 | - master 116 | 117 | -------------------------------------------------------------------------------- /src/extension/Content.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import React, { useEffect, useState, useRef } from "react"; 3 | import { App } from "../components/App"; 4 | import { useWindowLocation } from "../hooks/windowLocation"; 5 | import Modal from 'react-awesome-modal'; 6 | import { useGlobalState } from '../context/GlobalState'; 7 | import { SliceBtn } from "../components/SliceBtn"; 8 | 9 | const Emitter = require("tiny-emitter"); 10 | const emitter = new Emitter(); 11 | 12 | 13 | function updateDom(count = 0) { 14 | let files = document.getElementsByClassName("file-header"); 15 | if (files && files.length !== count) { 16 | count = files.length; 17 | for (let i = 0; i < files.length; ++i) { 18 | try{ 19 | const file = files[i]; 20 | const name = file.getElementsByClassName("file-info")[0].children[2] 21 | .title; 22 | 23 | const actions = file 24 | .getElementsByClassName("file-actions")[0].getElementsByClassName("d-flex")[0]; 25 | 26 | const existing = actions.getElementsByClassName("cherry-action"); 27 | if (existing.length === 0) { 28 | const elm = document.createElement("span"); 29 | elm.className = "cherry-action"; 30 | actions.prepend(elm); 31 | ReactDOM.render( 32 | <> 33 | { 36 | emitter.emit("file-slice", name); 37 | }} 38 | /> 39 | , 40 | elm 41 | ); 42 | } 43 | } catch(ex){ 44 | console.log("Unable to add a button for element", i, ex.message) 45 | } 46 | 47 | } 48 | } 49 | 50 | return files ? files.length : 0; 51 | } 52 | 53 | const GithubActionBtn = ({ title, action }) => { 54 | return ( 55 | <> 56 | 62 | {title} 63 | 64 | 65 | ); 66 | }; 67 | 68 | const useDomUpdated = (): MutationEvent => { 69 | const [nodes, setNodes] = useState(undefined); 70 | 71 | useEffect(() => { 72 | document.addEventListener( 73 | "DOMNodeInserted", 74 | val => { 75 | setNodes(val); 76 | }, 77 | false 78 | ); 79 | }, []); 80 | 81 | return nodes; 82 | }; 83 | 84 | const useInject = () => { 85 | const ref = useRef(); 86 | const nodes = useDomUpdated(); 87 | 88 | // Inject the slice button 89 | useEffect( 90 | () => { 91 | ref.current = updateDom(ref.current); 92 | }, 93 | [nodes] 94 | ); 95 | 96 | }; 97 | 98 | const useInjectMainSliceBtn = ({emitter}) => { 99 | const location = useWindowLocation(); 100 | const [modalVisible, setModalVisible] = useGlobalState("modalVisible"); 101 | 102 | useEffect( 103 | () => { 104 | const toolbar = document.querySelector(".diffbar .pr-review-tools"); 105 | if (!toolbar) { 106 | return 107 | } 108 | 109 | const existing = document.querySelector(".cherry-pie-toolbar"); 110 | if (existing) { 111 | return 112 | } 113 | const app = document.createElement("div"); 114 | app.className = "diffbar-item cherry-pie-toolbar"; 115 | toolbar.insertBefore(app, toolbar.firstChild); 116 | ReactDOM.render(, app); 117 | }, 118 | [location] 119 | ); 120 | }; 121 | 122 | const ContentInject = () => { 123 | useInject(); 124 | useInjectMainSliceBtn({emitter}); 125 | const config = window.cherrypie; // Enable a way to inject config 126 | return ; 127 | }; 128 | 129 | const app = document.createElement("div"); 130 | document.body.appendChild(app); 131 | ReactDOM.render(, app); 132 | -------------------------------------------------------------------------------- /src/service/CherryPie.ts: -------------------------------------------------------------------------------- 1 | import { GithubService } from "./Github"; 2 | import { MessagesService } from "./Messages"; 3 | 4 | export class CherryPieService { 5 | public isInit = false; 6 | constructor( 7 | private readonly github: GithubService, 8 | private readonly messages: MessagesService 9 | ) {} 10 | 11 | static parsePrUrl(url): {owner: string, repo: string, number: number} | undefined { 12 | let parsed = url.split("/"); 13 | if (parsed[2] !== "github.com") { 14 | return undefined; 15 | } 16 | if (parsed[5] !== "pull") { 17 | return undefined; 18 | } 19 | 20 | return { owner: parsed[3], repo: parsed[4], number: Number(parsed[6]) }; 21 | }; 22 | 23 | async init({ 24 | username, 25 | password, 26 | token 27 | }: { 28 | username?: string; 29 | password?: string; 30 | token?: string; 31 | }) { 32 | await this.github.init({ username, password, token }); 33 | this.isInit = true; 34 | } 35 | 36 | client() { 37 | return this.github; 38 | } 39 | 40 | async slice({ 41 | paths, 42 | sourceBranch, 43 | targetBranch, 44 | baseBranch, 45 | message, 46 | createPr, 47 | prTitle, 48 | prBody 49 | }: { 50 | 51 | paths: string[]; 52 | sourceBranch: string; 53 | targetBranch: string; 54 | baseBranch: string; 55 | message?: string; 56 | createPr?: boolean; 57 | prTitle?: string; 58 | prBody?: string; 59 | }) { 60 | const baseSha = await this.getBaseSha( 61 | sourceBranch, 62 | baseBranch 63 | ); 64 | this.messages.print({title: `Verifying branch`, text: `creating ${targetBranch} based on ${baseBranch}`}); 65 | await this.verifyTarget(targetBranch, baseSha); 66 | 67 | // Prepare the blobs 68 | this.messages.print({title: `Preparing tree`, text: `with ${paths.length} updates from ${sourceBranch} `}); 69 | const files = paths.map(path => ({ path })); 70 | const blobs = await this.github.prepareTree(files, sourceBranch); 71 | 72 | // Create the tree from all the blobs 73 | this.messages.print({title: `Creating tree`,text: `based on ${baseSha}`}); 74 | const tree = await this.github.createTree(baseSha, blobs); 75 | 76 | // Prepare the message 77 | if (!message) { 78 | message = this.prepareCommitMessage(sourceBranch, paths.length); 79 | } 80 | 81 | // Create a commit with the tree 82 | this.messages.print({title: `Preparing commit`, text: `for tree ${tree.sha} from ref ${baseSha}`}); 83 | const commit = await this.github.prepareCommit(message, tree.sha, baseSha); 84 | 85 | // Push the commit to the head ref 86 | this.messages.print({title: `Updating ref`, text: `${commit.sha} into ${targetBranch} (force: true)`}); 87 | const pushed = await this.github.pushToBranch(commit.sha, targetBranch); 88 | 89 | let pr; 90 | if(createPr && prTitle){ 91 | this.messages.print({title: `Creating pull request`, text: `from ${targetBranch} into ${baseBranch}`}); 92 | pr = await this.github.createPr({title: prTitle, body: prBody, headBranch: targetBranch, baseBranch }); 93 | 94 | } 95 | let text = `into ${pushed.ref}`; 96 | if(pr){ 97 | text = text + ` and created ${pr.url}` 98 | } 99 | this.messages.print( 100 | {title: `Finished pushing changes`, text} 101 | ); 102 | 103 | 104 | return pushed; 105 | } 106 | 107 | private async verifyTarget(targetBranch, baseSha) { 108 | let targetSha; 109 | 110 | try { 111 | const getTarget = await this.github.getBranch(targetBranch); 112 | targetSha = getTarget.commit.sha; 113 | } catch (ex) { 114 | if (ex.message === "Branch not found") { 115 | const target = await this.github.createBranch(targetBranch, baseSha); 116 | targetSha = target.object.sha; 117 | } else { 118 | throw ex; 119 | } 120 | } 121 | 122 | return targetSha; 123 | } 124 | 125 | private async getBaseSha(sourceBranch, baseBranch) { 126 | let base; 127 | let baseSha; 128 | 129 | try { 130 | base = await this.github.getBranch(baseBranch); 131 | baseSha = base.commit.sha; 132 | } catch (ex) { 133 | if (ex.message === "Branch not found") { 134 | throw Error(`base branch ${baseBranch} doesn't exists`); 135 | } 136 | } 137 | 138 | return baseSha; 139 | } 140 | 141 | private prepareCommitMessage(sourceBranch, fileCount) { 142 | return `Adding ${fileCount} files that were sliced from ${sourceBranch}`; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/components/Execute.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState} from "react"; 2 | import { useSlices } from "../hooks/slices"; 3 | import { CherryContext } from "../context/Cherry"; 4 | import { useGlobalState } from "../context/GlobalState"; 5 | import { useSlice } from "../hooks/cherry"; 6 | import { ProcessStatus } from "../constants"; 7 | import { useGlobalStore, dispatch } from "../context/GlobalStore"; 8 | import Octicon, { Check, X, MarkGithub } from "@githubprimer/octicons-react"; 9 | import { useCurrentPr } from "../hooks/currentPr"; 10 | import { CloseBtn } from "./CloseBtn"; 11 | 12 | export const Execute = props => { 13 | const [sliceInfo, setSliceInfo] = useGlobalState("sliceInfo"); 14 | 15 | const { slices, setSlices } = useSlices(); 16 | const { status, error, slice } = useSlice(); 17 | const [messages] = useGlobalStore("messages"); 18 | const [route, setRoute] = useGlobalState("route"); 19 | const { pr } = useCurrentPr(); 20 | const [cantRun, setCantRun] = useState(undefined); 21 | useEffect( 22 | () => { 23 | if (!pr) { 24 | return setCantRun("Pull request not loaded") 25 | } 26 | if(!sliceInfo || !sliceInfo.title || !sliceInfo.target){ 27 | return setCantRun("Missing slice info"); 28 | } 29 | slice({ 30 | paths: slices, 31 | sourceBranch: pr.head.ref, 32 | targetBranch: sliceInfo.target, 33 | baseBranch: pr.base.ref, 34 | createPr: true, 35 | message: sliceInfo.title, 36 | prTitle: sliceInfo.title, 37 | prBody: sliceInfo.body 38 | }); 39 | }, 40 | [pr, sliceInfo] 41 | ); 42 | 43 | if (!pr) { 44 | return
Pull request no loaded
; 45 | } 46 | 47 | return ( 48 |
49 |
50 |

EXECUTING

51 |
52 | 53 | 54 | {cantRun && } 55 |
56 | {messages.map((message, index) => { 57 | let Icon = ; 58 | const isLast = index === messages.length - 1; 59 | if (isLast) { 60 | if (status === ProcessStatus.Failed) { 61 | Icon = ; 62 | } else if (status === ProcessStatus.Working) { 63 | Icon = ( 64 | 69 | ); 70 | } 71 | } 72 | 73 | return ( 74 |
81 |
82 | {Icon} 83 |
84 |
85 | {message.title} 86 |
{message.text}
87 |
88 |
89 | ); 90 | })} 91 |
92 |
93 | 101 | { 103 | if (status === ProcessStatus.Done) { 104 | setSlices([]); 105 | } 106 | }} 107 | > 108 | 113 | 114 |
115 |
116 | ); 117 | }; 118 | 119 | const ErrorMessage = ({ error }) => { 120 | error = error ? error : "Unknown Error"; 121 | return ( 122 |
Slice failed: {error}
123 | ); 124 | }; 125 | const SuccessMessage = () => { 126 | return ( 127 |
128 | Slices served, enjoy your pie 129 |
130 | ); 131 | }; 132 | const WorkingMessage = () => { 133 | return
Slicing...
; 134 | }; 135 | const RenderStatus = ({ status, error }) => { 136 | switch (status) { 137 | case ProcessStatus.Working: 138 | return ; 139 | case ProcessStatus.Done: 140 | return ; 141 | case ProcessStatus.Failed: 142 | return ; 143 | default: 144 | return
; 145 | } 146 | }; 147 | -------------------------------------------------------------------------------- /src/components/Review.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext, useState } from "react"; 2 | import { useSlices } from "../hooks/slices"; 3 | import { useGlobalState } from "../context/GlobalState"; 4 | import { CherryContext } from "../context/Cherry"; 5 | import shortid from "shortid"; 6 | import { useCurrentPr } from "../hooks/currentPr"; 7 | import { CloseBtn } from "./CloseBtn"; 8 | import { Formik, Form, Field } from "formik"; 9 | 10 | export const Review = () => { 11 | const cherry = useContext(CherryContext); 12 | const { slices, removeSlice } = useSlices(); 13 | const [route, setRoute] = useGlobalState("route"); 14 | const [sliceInfo, setSliceInfo] = useGlobalState("sliceInfo"); 15 | const [target, setTarget] = useState(); 16 | const { pr } = useCurrentPr(); 17 | if (!pr) { 18 | return
Pull request not loaded
; 19 | } 20 | 21 | const source = pr.head.ref; 22 | 23 | const validate = values => { 24 | let errors: any = {}; 25 | if (!values.title) { 26 | errors.title = "Required"; 27 | } 28 | if (!values.target) { 29 | errors.target = "Required"; 30 | } 31 | 32 | return errors as any; 33 | }; 34 | 35 | useEffect(()=>{ 36 | setTarget(source + "-" + shortid.generate()); 37 | }, []) 38 | 39 | const initialValues = sliceInfo ? sliceInfo : { 40 | target, 41 | body: `supporting: #${pr.number}` 42 | }; 43 | 44 | return ( 45 |
46 | { 51 | setSliceInfo(values); 52 | setRoute("/execute"); 53 | }} 54 | > 55 | {props => { 56 | const { 57 | values, 58 | touched, 59 | errors, 60 | dirty, 61 | isSubmitting, 62 | handleChange, 63 | handleBlur, 64 | handleSubmit, 65 | handleReset 66 | } = props; 67 | const { target, title, body } = values; 68 | 69 | return ( 70 | <> 71 |
72 |

REVIEW CHANGES

73 |
74 | 75 |
76 | {`You are about to slice ${ 77 | slices.length 78 | } updates from ${source}`} 79 |
80 | 81 |
82 |
83 |
84 |
Open pull request
85 |
86 | 87 |
88 |
89 | 90 |
91 |
92 | 93 | 94 |
95 |
{errors.title}
96 | 97 |
98 |
99 |
100 | 101 |
102 |
103 |