├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .stylelintrc.json ├── .prettierrc ├── media ├── 1-notification.gif ├── 2-create-branch.gif ├── 3-push-branch.gif ├── 4-open-pr-github.gif └── 5-pr-notification.gif ├── src ├── renderer │ ├── index.css │ ├── index.html │ ├── plugins │ │ ├── dialogs.ts │ │ └── github.ts │ ├── ErrorBoundary.tsx │ ├── store.ts │ ├── components │ │ ├── Toolbar.tsx │ │ ├── styles.tsx │ │ └── TicketRow.tsx │ ├── modals │ │ ├── Select.tsx │ │ └── Settings.tsx │ ├── index.tsx │ └── App.tsx ├── main │ ├── helpers.ts │ ├── plugins │ │ ├── settings.ts │ │ ├── jenkins.ts │ │ ├── tray.ts │ │ ├── updateChecker.ts │ │ ├── windows.ts │ │ ├── touchBar.ts │ │ ├── server.ts │ │ ├── timer.ts │ │ └── git.ts │ ├── store.ts │ ├── helpers │ │ └── DiskSaver.ts │ ├── tasks │ │ └── push.ts │ └── index.ts └── shared │ ├── store │ ├── settings │ │ ├── reducers.ts │ │ └── actions.ts │ ├── branches │ │ ├── reducers.ts │ │ └── actions.ts │ └── tickets │ │ ├── reducers.ts │ │ └── actions.ts │ ├── types │ ├── branches.ts │ ├── settings.ts │ └── tickets.ts │ ├── types.ts │ ├── plugins │ ├── notifications.ts │ └── jira.ts │ ├── constants.ts │ └── helpers.ts ├── webpack.renderer.config.js ├── webpack.main.config.js ├── Info.plist ├── .babelrc ├── tsconfig.json ├── webpack.rules.js ├── .releaserc ├── CHANGELOG.md ├── .gitignore ├── README.md └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: a7madgamal 4 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-recommended", 3 | "rules": {} 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /media/1-notification.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a7madgamal/katibu/HEAD/media/1-notification.gif -------------------------------------------------------------------------------- /media/2-create-branch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a7madgamal/katibu/HEAD/media/2-create-branch.gif -------------------------------------------------------------------------------- /media/3-push-branch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a7madgamal/katibu/HEAD/media/3-push-branch.gif -------------------------------------------------------------------------------- /media/4-open-pr-github.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a7madgamal/katibu/HEAD/media/4-open-pr-github.gif -------------------------------------------------------------------------------- /media/5-pr-notification.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a7madgamal/katibu/HEAD/media/5-pr-notification.gif -------------------------------------------------------------------------------- /src/renderer/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html { 6 | background-color: #252525; 7 | } 8 | 9 | body { 10 | font-family: monospace; 11 | } 12 | -------------------------------------------------------------------------------- /src/main/helpers.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | // @ts-ignore 3 | const okk: (val: T) => Exclude = (val) => { 4 | assert.strict.ok(val) 5 | 6 | return val 7 | } 8 | 9 | export { okk } 10 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | const rules = require('./webpack.rules') 2 | 3 | module.exports = { 4 | module: { 5 | rules, 6 | }, 7 | resolve: { 8 | extensions: ['.ts', '.tsx', '.js', '.json'], 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /webpack.main.config.js: -------------------------------------------------------------------------------- 1 | const rules = require('./webpack.rules') 2 | 3 | module.exports = { 4 | entry: './src/main/index.ts', 5 | module: { 6 | rules, 7 | }, 8 | resolve: { 9 | extensions: ['.ts', '.tsx', '.js', '.json'], 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSUserNotificationAlertStyle 6 | alert 7 | 8 | -------------------------------------------------------------------------------- /src/renderer/plugins/dialogs.ts: -------------------------------------------------------------------------------- 1 | const { dialog, getCurrentWindow } = require('electron').remote 2 | 3 | const folderPicker = async () => { 4 | const folder = await dialog.showOpenDialog(getCurrentWindow(), { 5 | properties: ['openDirectory'], 6 | }) 7 | return folder 8 | } 9 | 10 | export { folderPicker } 11 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | "@babel/preset-typescript" 5 | // [ 6 | // "@emotion/babel-preset-css-prop", 7 | // { 8 | // "autoLabel": true, 9 | // "labelFormat": "[local]" 10 | // } 11 | // ] 12 | ] 13 | // "plugins": ["@emotion"] 14 | } 15 | -------------------------------------------------------------------------------- /src/main/plugins/settings.ts: -------------------------------------------------------------------------------- 1 | import { DiskSaver } from '../helpers/DiskSaver' 2 | import { INITIAL_SETTINGS } from '../../shared/constants' 3 | import { ISettingsState } from '../../shared/types/settings' 4 | 5 | const settingsPlugin = new DiskSaver({ 6 | configName: 'user-preferences', 7 | defaults: INITIAL_SETTINGS, 8 | }) 9 | 10 | export { settingsPlugin } 11 | -------------------------------------------------------------------------------- /src/main/plugins/jenkins.ts: -------------------------------------------------------------------------------- 1 | // https://jenkins_url/me/configure 2 | // import nodeJenkins from 'node-jenkins' 3 | 4 | // const jenkins = new nodeJenkins( 5 | // 'a7madgamal', 6 | // '11b00872adc5d8c681f54037b619d28d37', 7 | // 'https://jenkins.qa.auto1.team' 8 | // ) 9 | 10 | // const getInfo = async () => { 11 | // return await jenkins.info() 12 | // } 13 | 14 | // export { getInfo } 15 | -------------------------------------------------------------------------------- /src/main/plugins/tray.ts: -------------------------------------------------------------------------------- 1 | // import path from 'path' 2 | // import { Tray } from 'electron' 3 | 4 | // @ts-ignore 5 | // import image from '../assets/16.png' 6 | 7 | // var tray: Tray 8 | 9 | // const setContextMenu = () => { 10 | // const icon = path.join(__dirname, image) 11 | 12 | // tray = new Tray(icon) 13 | 14 | // // contextMenu = Menu.buildFromTemplate(generateTemplate()) 15 | // // tray.setContextMenu(contextMenu) 16 | // } 17 | 18 | // export { setContextMenu } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "removeComments": false, 4 | "preserveConstEnums": true, 5 | "sourceMap": true, 6 | "strict": true, 7 | "noUnusedLocals": true, 8 | "noUnusedParameters": true, 9 | "moduleResolution": "node", 10 | "allowSyntheticDefaultImports": true, 11 | "pretty": true, 12 | "target": "es2018", 13 | "module": "es2020", 14 | "skipLibCheck": true, 15 | "jsx": "react" 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /src/shared/store/settings/reducers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SAVE_SETTINGS, 3 | ISettingsState, 4 | TSettingsActionTypes, 5 | LOAD_SETTINGS, 6 | } from '../../../shared/types/settings' 7 | import { INITIAL_SETTINGS } from '../../constants' 8 | 9 | export function settingsReducer( 10 | state: ISettingsState = INITIAL_SETTINGS, 11 | action: TSettingsActionTypes, 12 | ): ISettingsState { 13 | switch (action.type) { 14 | case SAVE_SETTINGS: 15 | case LOAD_SETTINGS: 16 | return { 17 | ...state, 18 | ...action.payload, 19 | } 20 | 21 | default: 22 | return state 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /webpack.rules.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | test: /\.tsx?$/, 4 | exclude: /(node_modules|.webpack)/, 5 | loaders: [ 6 | { 7 | loader: 'ts-loader', 8 | options: { 9 | transpileOnly: true, 10 | }, 11 | }, 12 | ], 13 | }, 14 | 15 | { 16 | test: /\.css$/, 17 | use: [{ loader: 'style-loader' }, { loader: 'css-loader' }], 18 | }, 19 | { 20 | test: /\.(png|jpe?g|gif)$/, 21 | use: [ 22 | { 23 | loader: 'file-loader', 24 | options: { 25 | outputPath: 'assets', 26 | }, 27 | }, 28 | ], 29 | }, 30 | ] 31 | -------------------------------------------------------------------------------- /src/shared/types/branches.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_BRANCHES_UPDATED = 'LOCAL_BRANCHES_UPDATED' 2 | export const LOAD_BRANCHES = 'LOAD_BRANCHES' 3 | 4 | export type TBranches = Array<{ 5 | isCheckedout: boolean 6 | name: string 7 | repoId: string 8 | orgID: string 9 | isRemote: boolean 10 | }> 11 | 12 | export interface IBranchState { 13 | branches: TBranches 14 | isFetchingBranches: boolean 15 | } 16 | 17 | interface IBranchesUpdatedAction { 18 | type: typeof LOCAL_BRANCHES_UPDATED 19 | payload: TBranches 20 | } 21 | 22 | interface ILoadBranchesAction { 23 | type: typeof LOAD_BRANCHES 24 | } 25 | 26 | export type TBranchesUpdated = IBranchesUpdatedAction | ILoadBranchesAction 27 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "tagFormat": "v${version}-beta", 3 | "plugins": [ 4 | [ 5 | "@semantic-release/commit-analyzer", 6 | { 7 | "preset": "conventionalcommits" 8 | } 9 | ], 10 | "@semantic-release/release-notes-generator", 11 | [ 12 | "@semantic-release/changelog", 13 | { 14 | "changelogFile": "CHANGELOG.md" 15 | } 16 | ], 17 | [ 18 | "@semantic-release/npm" 19 | ], 20 | [ 21 | "@semantic-release/git", 22 | { 23 | "message": "chore(release): ${nextRelease.version} \n\n${nextRelease.notes}", 24 | "assets": [ 25 | "package.json", 26 | "CHANGELOG.md" 27 | ] 28 | } 29 | ] 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /src/shared/store/branches/reducers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IBranchState, 3 | TBranchesUpdated, 4 | LOCAL_BRANCHES_UPDATED, 5 | LOAD_BRANCHES, 6 | } from '../../types/branches' 7 | 8 | const initialState: IBranchState = { 9 | branches: [], 10 | isFetchingBranches: true, 11 | } 12 | 13 | export function branchesReducer( 14 | state = initialState, 15 | action: TBranchesUpdated, 16 | ): IBranchState { 17 | switch (action.type) { 18 | case LOCAL_BRANCHES_UPDATED: 19 | return { 20 | ...state, 21 | branches: [...action.payload], 22 | isFetchingBranches: false, 23 | } 24 | 25 | case LOAD_BRANCHES: 26 | return { 27 | ...state, 28 | isFetchingBranches: true, 29 | } 30 | 31 | default: 32 | return state 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from 'redux' 2 | import thunkMiddleware from 'redux-thunk' 3 | // @ts-ignore 4 | import { forwardToRenderer, replayActionMain } from 'electron-redux' 5 | 6 | import { settingsReducer } from '../shared/store/settings/reducers' 7 | import { ticketsReducer } from '../shared/store/tickets/reducers' 8 | import { branchesReducer } from '../shared/store/branches/reducers' 9 | 10 | const rootReducer = combineReducers({ 11 | settings: settingsReducer, 12 | tickets: ticketsReducer, 13 | branches: branchesReducer, 14 | }) 15 | 16 | export type TAppState = ReturnType 17 | 18 | const store = createStore( 19 | rootReducer, 20 | applyMiddleware(thunkMiddleware, forwardToRenderer), 21 | ) 22 | 23 | replayActionMain(store) 24 | 25 | const getMainStore = () => { 26 | return store 27 | } 28 | 29 | export { getMainStore } 30 | -------------------------------------------------------------------------------- /src/renderer/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | // @ts-ignore 3 | import electronTimber from 'electron-timber' 4 | 5 | const logger = electronTimber.create({ name: 'ErrorBoundary' }) 6 | 7 | class ErrorBoundary extends Component<{}, { hasError: boolean }> { 8 | constructor(props: {}) { 9 | super(props) 10 | this.state = { hasError: false } 11 | } 12 | 13 | static getDerivedStateFromError(_error: Error) { 14 | return { hasError: true } 15 | } 16 | 17 | componentDidCatch(error: any, errorInfo: any) { 18 | logger.error('🔴 componentDidCatch', error, errorInfo) 19 | // remote.app.quit() 20 | } 21 | 22 | render() { 23 | if (this.state.hasError) { 24 | // You can render any custom fallback UI 25 | return

Something went wrong.

26 | } 27 | 28 | return this.props.children 29 | } 30 | } 31 | 32 | export { ErrorBoundary } 33 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { PromiseValue } from 'type-fest' 2 | import { Octokit } from '@octokit/rest' 3 | import { OctokitResponse } from '@octokit/types' 4 | import { pushTask } from '../main/tasks/push' 5 | 6 | export enum CheckConclusion { 7 | success = 'success', 8 | failure = 'failure', 9 | neutral = 'neutral', 10 | cancelled = 'cancelled', 11 | skipped = 'skipped', 12 | timed_out = 'timed_out', 13 | action_required = 'action_required', 14 | } 15 | 16 | export type TPullRequest = PromiseValue< 17 | ReturnType['pulls']['list']> 18 | >['data'] 19 | 20 | export type TExtendedPullRequest = OctokitResponse< 21 | PromiseValue['pulls']['get']>>['data'] 22 | >['data'] & { checksStatus: CheckConclusion } 23 | 24 | export type TPushTaskOptions = Parameters[0] 25 | 26 | export type TRepoRemote = { 27 | remoteName: string 28 | repoId: string 29 | orgID: string 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from 'redux' 2 | import thunkMiddleware from 'redux-thunk' 3 | import { 4 | forwardToMain, 5 | replayActionRenderer, 6 | getInitialStateRenderer, 7 | } from 'electron-redux' 8 | 9 | import { settingsReducer } from '../shared/store/settings/reducers' 10 | import { ticketsReducer } from '../shared/store/tickets/reducers' 11 | import { branchesReducer } from '../shared/store/branches/reducers' 12 | 13 | const rootReducer = combineReducers({ 14 | settings: settingsReducer, 15 | tickets: ticketsReducer, 16 | branches: branchesReducer, 17 | }) 18 | 19 | // todo: check 20 | const initialState = getInitialStateRenderer<{}>() 21 | 22 | const store = createStore( 23 | rootReducer, 24 | initialState, 25 | applyMiddleware(forwardToMain, thunkMiddleware), 26 | ) 27 | 28 | replayActionRenderer(store) 29 | 30 | const getRendererStore = () => { 31 | return store 32 | } 33 | 34 | export { getRendererStore } 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.5.0](https://github.com/a7madgamal/katibu/compare/v1.4.3-beta...v1.5.0-beta) (2021-02-16) 2 | 3 | 4 | ### Features 5 | 6 | * KTB-61: detect starting time show notification after ([#29](https://github.com/a7madgamal/katibu/issues/29)) ([fde8b8f](https://github.com/a7madgamal/katibu/commit/fde8b8feef405e7c1a08688f0cac1a234fada4b7)) 7 | 8 | ## [1.4.3](https://github.com/a7madgamal/katibu/compare/v1.4.2-beta...v1.4.3-beta) (2020-07-28) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * add raw unix files ([7a5bb8b](https://github.com/a7madgamal/katibu/commit/7a5bb8b8a00a995c8df88e8cc79c8e289fa81b81)) 14 | * log out folder for ubuntu ([5d014eb](https://github.com/a7madgamal/katibu/commit/5d014ebc7fd5df612c86bf24ef706f744fccce5b)) 15 | 16 | ## [1.4.3](https://github.com/a7madgamal/katibu/compare/v1.4.2-beta...v1.4.3-beta) (2020-07-28) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * log out folder for ubuntu ([5d014eb](https://github.com/a7madgamal/katibu/commit/5d014ebc7fd5df612c86bf24ef706f744fccce5b)) 22 | -------------------------------------------------------------------------------- /src/main/plugins/updateChecker.ts: -------------------------------------------------------------------------------- 1 | import { dialog } from 'electron' 2 | 3 | const packageJson = require('../../../package.json') 4 | const compareVersions = require('compare-versions') 5 | const { shell } = require('electron') 6 | const got = require('got') 7 | 8 | const updateChecker = async () => { 9 | const { version } = packageJson 10 | 11 | try { 12 | const response = await got( 13 | 'https://api.github.com/repos/a7madgamal/katibu/releases', 14 | ).json() 15 | 16 | if (response[0] && compareVersions(version, response[0].tag_name) === -1) { 17 | const result = await dialog.showMessageBox({ 18 | type: 'warning', 19 | buttons: ['yes!', 'meh'], 20 | defaultId: 0, 21 | message: 'new version found, download it now?', 22 | detail: `this is the release details: ${response[0].body}`, 23 | }) 24 | 25 | if (result.response === 0) { 26 | shell.openExternal(response[0].html_url) 27 | } 28 | } 29 | } catch (error) { 30 | console.log(error) 31 | } 32 | } 33 | 34 | export { updateChecker } 35 | -------------------------------------------------------------------------------- /src/shared/types/settings.ts: -------------------------------------------------------------------------------- 1 | export const SAVE_SETTINGS = 'SAVE_SETTINGS' 2 | export const LOAD_SETTINGS = 'LOAD_SETTINGS' 3 | 4 | export interface IRepoSetting { 5 | path: string 6 | remoteName: string 7 | repoId: string 8 | orgID: string 9 | enableAutoRefresh: boolean 10 | } 11 | 12 | export interface ISettingsProfile { 13 | id: string 14 | reposList: Array 15 | // port: number 16 | githubAuth: string 17 | githubUserName: string 18 | jiraHost: string 19 | jiraEmail: string 20 | jiraAuth: string 21 | jiraJQL: string 22 | isTimeTrackerEnabled: boolean 23 | } 24 | 25 | export interface ISettingsState { 26 | activeProfile: string 27 | profiles: Array 28 | } 29 | 30 | interface ISaveSettingsAction { 31 | type: typeof SAVE_SETTINGS 32 | payload: { 33 | settings: ISettingsState 34 | profileSettings: ISettingsProfile 35 | profileId: string 36 | } 37 | } 38 | 39 | interface ILoadSettingsAction { 40 | type: typeof LOAD_SETTINGS 41 | payload: ISettingsState 42 | } 43 | 44 | export type TSettingsActionTypes = ISaveSettingsAction | ILoadSettingsAction 45 | -------------------------------------------------------------------------------- /src/renderer/components/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | 3 | import React from 'react' 4 | import { Link } from 'react-router-dom' 5 | import { jsx, css } from '@emotion/react' 6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 7 | import { faSpinner } from '@fortawesome/free-solid-svg-icons' 8 | import { ClickableBadgeStyle, BadgeStyle } from './styles' 9 | 10 | interface IToolbarProps { 11 | onRefresh: () => void 12 | isBusy: boolean 13 | } 14 | 15 | const Toolbar: React.FC = ({ onRefresh, isBusy }) => { 16 | return ( 17 |
25 | onRefresh()} 27 | spin={isBusy} 28 | icon={faSpinner} 29 | color="white" 30 | css={css` 31 | margin: 3px; 32 | flex-grow: 1; 33 | `} 34 | /> 35 | 36 | 45 | settings 46 | 47 |
48 | ) 49 | } 50 | 51 | export { Toolbar } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 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 | # TypeScript cache 43 | *.tsbuildinfo 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | .env.test 63 | 64 | # parcel-bundler cache (https://parceljs.org/) 65 | .cache 66 | 67 | # next.js build output 68 | .next 69 | 70 | # nuxt.js build output 71 | .nuxt 72 | 73 | # vuepress build output 74 | .vuepress/dist 75 | 76 | # Serverless directories 77 | .serverless/ 78 | 79 | # FuseBox cache 80 | .fusebox/ 81 | 82 | # DynamoDB Local files 83 | .dynamodb/ 84 | 85 | # Webpack 86 | .webpack/ 87 | 88 | # Electron-Forge 89 | out/ 90 | -------------------------------------------------------------------------------- /src/main/helpers/DiskSaver.ts: -------------------------------------------------------------------------------- 1 | import { app, remote } from 'electron' 2 | import path from 'path' 3 | import fs from 'fs' 4 | import { okk } from '../helpers' 5 | // @ts-ignore 6 | import electronTimber from 'electron-timber' 7 | 8 | const logger = electronTimber.create({ name: 'DiskSaver' }) 9 | 10 | class DiskSaver { 11 | path: string 12 | data: Schema 13 | 14 | constructor(opts: { configName: string; defaults: Schema }) { 15 | const userDataPath = (app || remote.app).getPath('userData') 16 | 17 | this.path = path.join(okk(userDataPath), opts.configName + '.json') 18 | 19 | if (!fs.existsSync(this.path)) { 20 | fs.writeFileSync(this.path, JSON.stringify(opts.defaults)) 21 | } 22 | 23 | try { 24 | this.data = readDataFile(this.path) 25 | } catch (error) { 26 | logger.error('DiskSaver: reading failed, resetting', error) 27 | 28 | this.data = opts.defaults 29 | } 30 | } 31 | 32 | getAll() { 33 | return this.data 34 | } 35 | 36 | save(dataObj: Schema) { 37 | const data = JSON.stringify(dataObj) 38 | logger.log(`DiskSaver: saving "${this.path}"`, data) 39 | 40 | try { 41 | fs.writeFileSync(this.path, data) 42 | this.data = dataObj 43 | } catch (error) { 44 | logger.error('save failed', error) 45 | alert(`cant save ${this.data} to ${this.path}`) 46 | } 47 | } 48 | } 49 | 50 | function readDataFile(filePath: string) { 51 | const savedOptions = fs.readFileSync(filePath) 52 | logger.log({ filePath }) 53 | 54 | return JSON.parse(savedOptions.toString()) 55 | } 56 | 57 | export { DiskSaver } 58 | -------------------------------------------------------------------------------- /src/shared/store/tickets/reducers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TJiraTicketsAction, 3 | IJiraTicketsState, 4 | LOAD_JIRA_TICKETS, 5 | LOADING_JIRA_TICKETS_SUCCESS, 6 | LOADING_JIRA_TICKETS_FAIL, 7 | LOADING_PRS_SUCCESS, 8 | LOADING_PRS_FAIL, 9 | LOAD_PRS, 10 | } from '../../types/tickets' 11 | 12 | const initialState: IJiraTicketsState = { 13 | ticketsData: [], 14 | pullRequests: [], 15 | isFetchingTickets: true, 16 | isFetchingPRs: true, 17 | } 18 | 19 | export function ticketsReducer( 20 | state = initialState, 21 | action: TJiraTicketsAction, 22 | ): IJiraTicketsState { 23 | switch (action.type) { 24 | case LOAD_JIRA_TICKETS: 25 | return { 26 | ...state, 27 | isFetchingTickets: true, 28 | } 29 | 30 | case LOADING_JIRA_TICKETS_SUCCESS: 31 | return { 32 | ...state, 33 | ticketsData: [...action.payload], 34 | isFetchingTickets: false, 35 | } 36 | 37 | // todo: handle fail 38 | case LOADING_JIRA_TICKETS_FAIL: 39 | return { 40 | ...state, 41 | ticketsData: false, 42 | isFetchingTickets: false, 43 | } 44 | 45 | case LOADING_PRS_SUCCESS: 46 | return { 47 | ...state, 48 | pullRequests: [...action.payload], 49 | isFetchingPRs: false, 50 | } 51 | 52 | case LOADING_PRS_FAIL: 53 | return { 54 | ...state, 55 | pullRequests: false, 56 | isFetchingPRs: false, 57 | } 58 | 59 | case LOAD_PRS: 60 | return { 61 | ...state, 62 | isFetchingPRs: true, 63 | } 64 | default: 65 | return state 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/shared/types/tickets.ts: -------------------------------------------------------------------------------- 1 | import { TExtendedPullRequest } from '../types' 2 | 3 | export const LOADING_JIRA_TICKETS_SUCCESS = 'LOADING_JIRA_TICKETS_SUCCESS' 4 | export const LOADING_JIRA_TICKETS_FAIL = 'LOADING_JIRA_TICKETS_FAIL' 5 | export const LOAD_JIRA_TICKETS = 'LOAD_JIRA_TICKETS' 6 | 7 | export const LOADING_PRS_SUCCESS = 'LOADING_PRS_SUCCESS' 8 | export const LOADING_PRS_FAIL = 'LOADING_PRS_FAIL' 9 | export const LOAD_PRS = 'LOAD_PRS' 10 | 11 | export interface IJiraTicket { 12 | id: string 13 | key: string 14 | fields: { 15 | summary: string 16 | status: { 17 | name: string 18 | } 19 | assignee: { 20 | avatarUrls: { [key: string]: string } 21 | } 22 | } 23 | } 24 | 25 | export interface IJiraTicketsState { 26 | ticketsData: Array | false 27 | pullRequests: Array | false 28 | isFetchingTickets: boolean 29 | isFetchingPRs: boolean 30 | } 31 | 32 | interface ILoadJiraTicketsAction { 33 | type: typeof LOAD_JIRA_TICKETS 34 | } 35 | 36 | interface ILoadJiraTicketsSuccessAction { 37 | type: typeof LOADING_JIRA_TICKETS_SUCCESS 38 | payload: Array 39 | } 40 | 41 | interface ILoadJiraTicketsFailAction { 42 | type: typeof LOADING_JIRA_TICKETS_FAIL 43 | } 44 | 45 | interface ILoadPRsFailAction { 46 | type: typeof LOADING_PRS_FAIL 47 | } 48 | 49 | interface ILoadPRsAction { 50 | type: typeof LOAD_PRS 51 | } 52 | 53 | interface ILoadPRsSuccessAction { 54 | type: typeof LOADING_PRS_SUCCESS 55 | payload: Array 56 | } 57 | 58 | export type TJiraTicketsAction = 59 | | ILoadJiraTicketsAction 60 | | ILoadJiraTicketsSuccessAction 61 | | ILoadJiraTicketsFailAction 62 | | ILoadPRsFailAction 63 | | ILoadPRsSuccessAction 64 | | ILoadPRsAction 65 | -------------------------------------------------------------------------------- /src/shared/plugins/notifications.ts: -------------------------------------------------------------------------------- 1 | import { remote, Notification, NotificationConstructorOptions } from 'electron' 2 | 3 | const _Notification = Notification || remote.Notification 4 | 5 | const showNotification = ( 6 | options: NotificationConstructorOptions, 7 | autoClose: boolean = true, 8 | onClick?: () => void, 9 | ) => { 10 | const notification = new _Notification({ 11 | silent: true, 12 | timeoutType: autoClose ? 'default' : 'never', 13 | ...options, 14 | // hasReply: true, 15 | // actions: [{ type: 'button', text: 'obba' }], 16 | }) 17 | 18 | onClick && notification.on('click', onClick) 19 | 20 | // notification.on('reply', (e, reply) => { 21 | // console.log({ e, reply }) 22 | // }) 23 | 24 | // notification.on('action', (e, index) => { 25 | // console.log({ e, index }) 26 | // }) 27 | 28 | autoClose && setTimeout(() => notification.close(), 10000) 29 | 30 | notification.show() 31 | 32 | return notification 33 | // { 34 | // title, 35 | // body, 36 | // subtitle: 'subtitle' 37 | // silent: false, 38 | // icon: '', 39 | // hasReply: false, 40 | // replyPlaceholder: '', // String (optional) macOS - The placeholder to write in the inline reply input field. 41 | // sound: '', //String (optional) macOS - The name of the sound file to play when the notification is shown. 42 | // actions: [{ type: 'button', text: 'ok' }] //NotificationAction[] (optional) macOS - Actions to add to the notification. Please read the available actions and limitations in the NotificationAction documentation. 43 | // closeButtonText: '' // String (optional) macOS - A custom title for the close button of an alert. An empty string will cause the default localized text to be used. 44 | // ...options 45 | // } 46 | } 47 | 48 | export { showNotification } 49 | -------------------------------------------------------------------------------- /src/main/tasks/push.ts: -------------------------------------------------------------------------------- 1 | import { showNotification } from '../../shared/plugins/notifications' 2 | import { pushBranch } from '../plugins/git' 3 | import { getRepoSettingsFromId } from '../../shared/helpers' 4 | 5 | const pushTask = async ({ 6 | repoId, 7 | skipChecks, 8 | branchName, 9 | }: { 10 | repoId: string 11 | skipChecks?: boolean 12 | branchName?: string 13 | }) => { 14 | const repoSettings = await getRepoSettingsFromId(repoId) 15 | 16 | if (repoSettings) { 17 | const pushingNotification = showNotification( 18 | { 19 | title: `Pushing ${skipChecks ? 'without checks' : ''}...`, 20 | body: `[${repoSettings.remoteName}]${repoSettings.orgID}:${repoSettings.repoId}`, 21 | }, 22 | false, 23 | ) 24 | 25 | const result = await pushBranch({ 26 | repo: repoSettings, 27 | skipChecks, 28 | branchName, 29 | }) 30 | 31 | pushingNotification.close() 32 | 33 | if (result) { 34 | showNotification( 35 | { 36 | title: 'Pushed!', 37 | body: result, 38 | }, 39 | true, 40 | ) 41 | } else { 42 | showNotification( 43 | { 44 | title: 'push failed 🙀, click to force 💪🏻', 45 | body: '', 46 | }, 47 | false, 48 | async () => { 49 | const result = await pushBranch({ 50 | repo: repoSettings, 51 | skipChecks, 52 | forcePush: true, 53 | branchName, 54 | }) 55 | 56 | if (result) { 57 | showNotification( 58 | { 59 | title: 'Force Pushed!', 60 | body: result, 61 | }, 62 | true, 63 | ) 64 | } else { 65 | showNotification( 66 | { 67 | title: 'Force Push failed!', 68 | body: '', 69 | }, 70 | true, 71 | ) 72 | } 73 | }, 74 | ) 75 | } 76 | } 77 | } 78 | 79 | export { pushTask } 80 | -------------------------------------------------------------------------------- /src/shared/store/settings/actions.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux' 2 | import { ThunkAction } from 'redux-thunk' 3 | import { TAppState } from '../../../main/store' 4 | import { 5 | SAVE_SETTINGS, 6 | ISettingsState, 7 | ISettingsProfile, 8 | } from '../../types/settings' 9 | // @ts-ignore 10 | import electronTimber from 'electron-timber' 11 | import { ipcRenderer } from 'electron' 12 | import { IPC_RELOAD, IPC_SAVE_SETTINGS } from '../../constants' 13 | 14 | const logger = electronTimber.create({ name: 'settings/actions' }) 15 | 16 | export const deleteSettings = ( 17 | settings: ISettingsState, 18 | profileId: string, 19 | ): ThunkAction> => async (dispatch) => { 20 | 21 | const oldProfileIndex = settings.profiles.findIndex( 22 | (profile) => profile.id === profileId, 23 | ) 24 | 25 | if (settings.profiles.length > 1) { 26 | settings.profiles.splice(oldProfileIndex, 1) 27 | settings.activeProfile = settings.profiles[0].id 28 | } 29 | 30 | await ipcRenderer.invoke(IPC_SAVE_SETTINGS, settings) 31 | 32 | dispatch({ 33 | type: SAVE_SETTINGS, 34 | payload: settings, 35 | }) 36 | 37 | await ipcRenderer.invoke(IPC_RELOAD) 38 | } 39 | 40 | export const saveSettings = ( 41 | settings: ISettingsState, 42 | profileSettings: ISettingsProfile, 43 | profileId: string, 44 | ): ThunkAction> => async (dispatch) => { 45 | logger.log('saveSettings action', { settings, profileSettings, profileId }) 46 | 47 | const oldProfileIndex = settings.profiles.findIndex( 48 | (profile) => profile.id === profileId, 49 | ) 50 | 51 | if (oldProfileIndex !== -1) { 52 | settings.profiles[oldProfileIndex] = profileSettings 53 | } else { 54 | settings.profiles.push(profileSettings) 55 | } 56 | 57 | settings.activeProfile = profileId 58 | 59 | await ipcRenderer.invoke(IPC_SAVE_SETTINGS, settings) 60 | 61 | dispatch({ 62 | type: SAVE_SETTINGS, 63 | payload: settings, 64 | }) 65 | 66 | await ipcRenderer.invoke(IPC_RELOAD) 67 | } 68 | -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | import { ISettingsProfile, ISettingsState } from './types/settings' 2 | 3 | const IPC_RENDER_NAVIGATE_SELECTOR = 'IPC_RENDER_NAVIGATE_SELECTOR' 4 | const IPC_RENDER_NAVIGATE_HOME = 'IPC_RENDER_NAVIGATE_HOME' 5 | const IPC_RENDER_REFRESH_TICKETS = 'IPC_RENDER_REFRESH_TICKETS' 6 | const IPC_RENDER_REFRESH_PRS = 'IPC_RENDER_REFRESH_PRS' 7 | const IPC_RENDER_REFRESH_GIT = 'IPC_RENDER_REFRESH_GIT' 8 | const IPC_CHECKOUT_LOCAL_BRANCH = 'IPC_CHECKOUT_LOCAL_BRANCH' 9 | const IPC_CREATE_BRANCH = 'IPC_CREATE_BRANCH' 10 | const IPC_REBASE_BRANCH = 'IPC_REBASE_BRANCH' 11 | const IPC_DELETE_BRANCH = 'IPC_DELETE_BRANCH' 12 | const IPC_GET_BRANCHES = 'IPC_GET_BRANCHES' 13 | const IPC_PUSH_BRANCH = 'IPC_PUSH_BRANCH' 14 | const IPC_PULL_BRANCH = 'IPC_PULL_BRANCH' 15 | const IPC_CANCEL_SELECT = 'IPC_CANCEL_SELECT' 16 | const IPC_HIDE_SELECT = 'IPC_HIDE_SELECT' 17 | const IPC_REPO_SELECT = 'IPC_REPO_SELECT' 18 | const IPC_GET_GIT_REMOTE = 'IPC_GET_GIT_REMOTE' 19 | const IPC_RELOAD = 'IPC_RELOAD' 20 | const IPC_SAVE_SETTINGS = 'IPC_SAVE_SETTINGS' 21 | const IPC_LOAD_SETTINGS = 'IPC_LOAD_SETTINGS' 22 | 23 | export const INITIAL_PROFILE: ISettingsProfile = { 24 | id: 'default', 25 | reposList: [], 26 | githubAuth: '', 27 | githubUserName: '', 28 | jiraHost: '', 29 | jiraEmail: '', 30 | jiraAuth: '', 31 | jiraJQL: 'assignee in (currentUser())', 32 | isTimeTrackerEnabled: true, 33 | } 34 | 35 | export const INITIAL_SETTINGS: ISettingsState = { 36 | activeProfile: 'default', 37 | profiles: [INITIAL_PROFILE], 38 | } 39 | 40 | export { 41 | IPC_RENDER_NAVIGATE_SELECTOR, 42 | IPC_RENDER_NAVIGATE_HOME, 43 | IPC_RENDER_REFRESH_TICKETS, 44 | IPC_RENDER_REFRESH_PRS, 45 | IPC_RENDER_REFRESH_GIT, 46 | IPC_CHECKOUT_LOCAL_BRANCH, 47 | IPC_CREATE_BRANCH, 48 | IPC_REBASE_BRANCH, 49 | IPC_DELETE_BRANCH, 50 | IPC_GET_BRANCHES, 51 | IPC_PUSH_BRANCH, 52 | IPC_PULL_BRANCH, 53 | IPC_CANCEL_SELECT, 54 | IPC_REPO_SELECT, 55 | IPC_HIDE_SELECT, 56 | IPC_GET_GIT_REMOTE, 57 | IPC_RELOAD, 58 | IPC_SAVE_SETTINGS, 59 | IPC_LOAD_SETTINGS, 60 | } 61 | -------------------------------------------------------------------------------- /src/renderer/components/styles.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | 3 | import { css } from '@emotion/react' 4 | import styled from '@emotion/styled' 5 | 6 | const titlesColor = '#fff' 7 | const ticketInProgressBGColor = '#0052cc' 8 | const ticketInProgressColor = '#fff' 9 | const ticketInactiveBGColor = '#999' 10 | const ticketInactiveColor = '#000' 11 | const actionsColor = '#333' 12 | const cardsBGColor = '#ddd' 13 | const activeCardAccentColor = '#00cc00' 14 | // const remoteCardColor = '' 15 | // const remoteCardBGColor = '' 16 | const borderColor = '#bfbfbf' 17 | 18 | const BadgeStyle = css` 19 | cursor: default; 20 | display: inline-flex; 21 | margin: 5px; 22 | padding: 5px; 23 | border-radius: 5px; 24 | border-radius: 4px; 25 | width: fit-content; 26 | align-items: center; 27 | justify-content: center; 28 | flex-shrink: 0; 29 | flex-grow: 0; 30 | ` 31 | const ClickableBadgeStyle = css` 32 | transition: 0.3s; 33 | cursor: pointer; 34 | color: ${actionsColor}; 35 | box-shadow: 0px 0px 1px #fff; 36 | margin-left: 3px; 37 | 38 | :hover { 39 | box-shadow: 1px 1px 1px #fff; 40 | } 41 | ` 42 | 43 | const TextFieldWrapper = styled.div` 44 | width: 400px; 45 | display: flex; 46 | flex-direction: column; 47 | margin: 10px; 48 | ` 49 | const Error = styled.span` 50 | color: red; 51 | ` 52 | 53 | const textColor = '#cacaca' 54 | const settingLinkColor = '#8bc34a' 55 | 56 | const Label = styled.label` 57 | font-size: 18px; 58 | margin-bottom: 2px; 59 | color: ${textColor}; 60 | ` 61 | 62 | const SupportLink = styled.span` 63 | margin-left: 10px; 64 | display: inline; 65 | cursor: pointer; 66 | font-size: 18px; 67 | color: ${settingLinkColor}; 68 | 69 | :hover { 70 | color: white; 71 | } 72 | ` 73 | 74 | export { 75 | BadgeStyle, 76 | ClickableBadgeStyle, 77 | TextFieldWrapper, 78 | Error, 79 | Label, 80 | SupportLink, 81 | textColor, 82 | settingLinkColor, 83 | titlesColor, 84 | ticketInProgressColor, 85 | ticketInProgressBGColor, 86 | ticketInactiveColor, 87 | actionsColor, 88 | cardsBGColor, 89 | activeCardAccentColor, 90 | // remoteCardColor, 91 | // remoteCardBGColor, 92 | borderColor, 93 | ticketInactiveBGColor, 94 | } 95 | -------------------------------------------------------------------------------- /src/main/plugins/windows.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, screen, ipcMain, app } from 'electron' 2 | import { 3 | IPC_RENDER_NAVIGATE_SELECTOR, 4 | IPC_CANCEL_SELECT, 5 | IPC_REPO_SELECT, 6 | } from '../../shared/constants' 7 | 8 | var mainWindow: BrowserWindow 9 | var selectWindow: BrowserWindow 10 | 11 | const createAppWindow = () => { 12 | const { width, height } = screen.getPrimaryDisplay().workAreaSize 13 | 14 | mainWindow = new BrowserWindow({ 15 | // show: false, 16 | width: 900, 17 | height: 500, 18 | x: width / 2 - 900, 19 | y: height / 2 - 500, 20 | // frame: false, 21 | webPreferences: { 22 | nodeIntegration: true, 23 | enableRemoteModule: true, 24 | }, 25 | }) 26 | 27 | mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY) 28 | // mainWindow.webContents.openDevTools() 29 | mainWindow.on('closed', () => { 30 | app.exit() 31 | }) 32 | 33 | return mainWindow 34 | } 35 | 36 | const createSelectWindow = () => { 37 | const { width, height } = screen.getPrimaryDisplay().workAreaSize 38 | 39 | selectWindow = new BrowserWindow({ 40 | show: false, 41 | width: 400, 42 | height: 300, 43 | x: width / 2 - 300, 44 | y: height / 2 - 400, 45 | alwaysOnTop: true, 46 | // frame: false, 47 | webPreferences: { 48 | nodeIntegration: true, 49 | enableRemoteModule: true, 50 | }, 51 | }) 52 | 53 | selectWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY) 54 | // selectWindow.webContents.openDevTools() 55 | selectWindow.webContents.on('did-finish-load', () => { 56 | selectWindow.webContents.send(IPC_RENDER_NAVIGATE_SELECTOR) 57 | }) 58 | return selectWindow 59 | // mainWindow.on('closed', () => { 60 | // mainWindow = null 61 | // }) 62 | } 63 | 64 | const showRepoSelector = () => { 65 | selectWindow.show() 66 | 67 | return new Promise<{ repoId: string; path: string } | false>( 68 | (resolve, _reject) => { 69 | ipcMain.once(IPC_REPO_SELECT, (_e, { repoId, path }) => { 70 | resolve({ repoId, path }) 71 | }) 72 | ipcMain.once(IPC_CANCEL_SELECT, (_e) => { 73 | resolve(false) 74 | }) 75 | }, 76 | ) 77 | } 78 | 79 | export { createAppWindow, createSelectWindow, showRepoSelector } 80 | -------------------------------------------------------------------------------- /src/renderer/modals/Select.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | 3 | import React from 'react' 4 | import { ipcRenderer } from 'electron' 5 | import { css, jsx } from '@emotion/react' 6 | import { connect, ConnectedProps } from 'react-redux' 7 | import { TAppState } from '../../main/store' 8 | import { 9 | IPC_CANCEL_SELECT, 10 | IPC_REPO_SELECT, 11 | IPC_HIDE_SELECT, 12 | } from '../../shared/constants' 13 | import { getActiveSettings } from '../../shared/helpers' 14 | 15 | const connector = connect((state: TAppState) => ({ 16 | settings: getActiveSettings(state.settings), 17 | })) 18 | 19 | type TProps = ConnectedProps 20 | 21 | const select: React.FC = ({ settings }) => { 22 | return ( 23 |
30 | { 44 | await ipcRenderer.invoke(IPC_CANCEL_SELECT) 45 | }} 46 | > 47 | x 48 | 49 | 50 |
    57 | {settings.reposList.map(({ repoId, path }) => ( 58 |
  • { 74 | ipcRenderer.send(IPC_REPO_SELECT, { repoId, path }) 75 | ipcRenderer.send(IPC_HIDE_SELECT) 76 | }} 77 | > 78 | {repoId} 79 |
  • 80 | ))} 81 |
82 |
83 | ) 84 | } 85 | 86 | const Select = connector(select) 87 | 88 | export { Select } 89 | -------------------------------------------------------------------------------- /src/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import sourceMapSupport from 'source-map-support' 2 | sourceMapSupport.install() 3 | 4 | import electronUnhandled from 'electron-unhandled' 5 | electronUnhandled({ showDialog: true }) 6 | 7 | import './index.css' 8 | import React from 'react' 9 | import { Router } from 'react-router' 10 | import { createHashHistory } from 'history' 11 | import { Route } from 'react-router-dom' 12 | 13 | import { render } from 'react-dom' 14 | import { Provider } from 'react-redux' 15 | 16 | import { Settings } from './modals/Settings' 17 | import { App } from './App' 18 | import { Select } from './modals/Select' 19 | import { getRendererStore } from '../renderer/store' 20 | import { ErrorBoundary } from './ErrorBoundary' 21 | import { ipcRenderer } from 'electron' 22 | import { fetchTickets, fetchPRs } from '../shared/store/tickets/actions' 23 | import { fetchGit } from '../shared/store/branches/actions' 24 | import { 25 | IPC_RENDER_NAVIGATE_SELECTOR, 26 | IPC_RENDER_REFRESH_TICKETS, 27 | IPC_RENDER_REFRESH_PRS, 28 | IPC_RENDER_REFRESH_GIT, 29 | IPC_RENDER_NAVIGATE_HOME, 30 | } from '../shared/constants' 31 | 32 | const customHistory = createHashHistory() 33 | 34 | ipcRenderer.on(IPC_RENDER_NAVIGATE_SELECTOR, (_event) => { 35 | customHistory.replace('/select') 36 | }) 37 | 38 | ipcRenderer.on(IPC_RENDER_NAVIGATE_HOME, (_event) => { 39 | customHistory.replace('/') 40 | }) 41 | 42 | ipcRenderer.on(IPC_RENDER_REFRESH_TICKETS, (_event) => { 43 | fetchTickets(false)( 44 | getRendererStore().dispatch, 45 | getRendererStore().getState, 46 | null, 47 | ) 48 | }) 49 | 50 | ipcRenderer.on(IPC_RENDER_REFRESH_PRS, (_event) => { 51 | fetchPRs(false)( 52 | getRendererStore().dispatch, 53 | getRendererStore().getState, 54 | null, 55 | ) 56 | }) 57 | 58 | ipcRenderer.on(IPC_RENDER_REFRESH_GIT, (_event) => { 59 | fetchGit()(getRendererStore().dispatch, getRendererStore().getState, null) 60 | }) 61 | 62 | render( 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | , 74 | document.getElementById('app'), 75 | ) 76 | -------------------------------------------------------------------------------- /src/shared/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ISettingsState, ISettingsProfile } from './types/settings' 2 | // @ts-ignore 3 | import electronTimber from 'electron-timber' 4 | import { INITIAL_SETTINGS } from './constants' 5 | const logger = electronTimber.create({ name: '[SHARED:HELPERS]' }) 6 | 7 | const getRepoSettingsFromId = async (repoId: string) => { 8 | const isRenderer = process && process.type === 'renderer' 9 | 10 | let store 11 | if (isRenderer) { 12 | const { getRendererStore } = await import('../renderer/store') 13 | store = getRendererStore() 14 | } else { 15 | const { getMainStore } = await import('../main/store') 16 | store = getMainStore() 17 | } 18 | 19 | const state = store.getState() 20 | 21 | const repo = getActiveSettings(state.settings).reposList.find( 22 | (repo) => repo.repoId === repoId, 23 | ) 24 | 25 | if (repo) { 26 | return repo 27 | } else { 28 | throw new Error(`getRepoSettingsFromId failed in isRenderer:${isRenderer}`) 29 | } 30 | } 31 | 32 | const getProfileSettings = ( 33 | settings: ISettingsState, 34 | id: string, 35 | ): ISettingsProfile => { 36 | const profile = settings.profiles.find((profile) => profile.id === id) 37 | 38 | if (profile) { 39 | return profile 40 | } else { 41 | logger.log(`cant get profile settings for id: ${id}`) 42 | const newProfile = INITIAL_SETTINGS.profiles[0] 43 | newProfile.id = id 44 | 45 | return newProfile 46 | } 47 | } 48 | 49 | const getActiveSettings = (settings: ISettingsState) => { 50 | const profile = settings.profiles.find( 51 | (profile) => profile.id === settings.activeProfile, 52 | ) 53 | if (profile) { 54 | return profile 55 | } else { 56 | throw new Error('cant get profile settings') 57 | } 58 | } 59 | 60 | const areSettingsValid = (settings: ISettingsState) => 61 | settings.activeProfile && 62 | settings.profiles && 63 | settings.profiles.every( 64 | (profile) => 65 | profile.id && 66 | profile.githubAuth && 67 | profile.githubUserName && 68 | profile.jiraAuth && 69 | profile.jiraEmail && 70 | profile.jiraHost && 71 | profile.jiraJQL && 72 | profile.reposList.length && 73 | profile.reposList.every( 74 | (repo) => repo.orgID && repo.path && repo.remoteName && repo.repoId, 75 | ), 76 | ) 77 | 78 | export { 79 | getRepoSettingsFromId, 80 | areSettingsValid, 81 | getActiveSettings, 82 | getProfileSettings, 83 | } 84 | -------------------------------------------------------------------------------- /src/main/plugins/touchBar.ts: -------------------------------------------------------------------------------- 1 | // import { app, BrowserWindow, TouchBar } from 'electron' 2 | 3 | // const { TouchBarLabel, TouchBarButton, TouchBarSpacer } = TouchBar 4 | 5 | // let spinning = false 6 | 7 | // const reel1 = new TouchBarLabel({}) 8 | // const reel2 = new TouchBarLabel({}) 9 | // const reel3 = new TouchBarLabel({}) 10 | // const result = new TouchBarLabel({}) 11 | 12 | // const spin = new TouchBarButton({ 13 | // label: '🎰 Spin', 14 | // backgroundColor: '#7851A9', 15 | // click: () => { 16 | // // Ignore clicks if already spinning 17 | // if (spinning) { 18 | // return 19 | // } 20 | 21 | // spinning = true 22 | // result.label = '' 23 | 24 | // let timeout = 10 25 | // const spinLength = 4 * 1000 // 4 seconds 26 | // const startTime = Date.now() 27 | 28 | // const spinReels = () => { 29 | // updateReels() 30 | 31 | // if (Date.now() - startTime >= spinLength) { 32 | // finishSpin() 33 | // } else { 34 | // // Slow down a bit on each spin 35 | // timeout *= 1.1 36 | // setTimeout(spinReels, timeout) 37 | // } 38 | // } 39 | 40 | // spinReels() 41 | // }, 42 | // }) 43 | 44 | // const getRandomValue = () => { 45 | // const values = ['🍒', '💎', '7️⃣', '🍊', '🔔', '⭐', '🍇', '🍀'] 46 | // return values[Math.floor(Math.random() * values.length)] 47 | // } 48 | 49 | // const updateReels = () => { 50 | // reel1.label = getRandomValue() 51 | // reel2.label = getRandomValue() 52 | // reel3.label = getRandomValue() 53 | // } 54 | 55 | // const finishSpin = () => { 56 | // const uniqueValues = new Set([reel1.label, reel2.label, reel3.label]).size 57 | // if (uniqueValues === 1) { 58 | // // All 3 values are the same 59 | // result.label = '💰 Jackpot!' 60 | // result.textColor = '#FDFF00' 61 | // } else if (uniqueValues === 2) { 62 | // // 2 values are the same 63 | // result.label = '😍 Winner!' 64 | // result.textColor = '#FDFF00' 65 | // } else { 66 | // // No values are the same 67 | // result.label = '🙁 Spin Again' 68 | // result.textColor = null 69 | // } 70 | // spinning = false 71 | // } 72 | 73 | // const touchBar = new TouchBar({ 74 | // items: [ 75 | // spin, 76 | // new TouchBarSpacer({ size: 'large' }), 77 | // reel1, 78 | // new TouchBarSpacer({ size: 'small' }), 79 | // reel2, 80 | // new TouchBarSpacer({ size: 'small' }), 81 | // reel3, 82 | // new TouchBarSpacer({ size: 'large' }), 83 | // result, 84 | // ], 85 | // }) 86 | 87 | // export { touchBar } 88 | -------------------------------------------------------------------------------- /src/shared/store/branches/actions.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux' 2 | import { ThunkAction } from 'redux-thunk' 3 | 4 | import { showNotification } from '../../plugins/notifications' 5 | 6 | import { TAppState } from '../../../main/store' 7 | 8 | import { 9 | LOCAL_BRANCHES_UPDATED, 10 | TBranches, 11 | LOAD_BRANCHES, 12 | } from '../../types/branches' 13 | // @ts-ignore 14 | import electronTimber from 'electron-timber' 15 | import { 16 | IPC_DELETE_BRANCH, 17 | IPC_GET_BRANCHES, 18 | IPC_CHECKOUT_LOCAL_BRANCH, 19 | IPC_PULL_BRANCH, 20 | } from '../../constants' 21 | import { ipcRenderer } from 'electron' 22 | import { BranchSummary } from 'simple-git/promise' 23 | import { getActiveSettings } from '../../helpers' 24 | const logger = electronTimber.create({ name: 'branches/actions' }) 25 | 26 | export const fetchGit = (): ThunkAction< 27 | void, 28 | TAppState, 29 | null, 30 | Action 31 | > => async (dispatch, getState) => { 32 | dispatch({ type: LOAD_BRANCHES }) 33 | 34 | const state = getState() 35 | let newBranches: TBranches = [] 36 | 37 | for (const repo of getActiveSettings(state.settings).reposList) { 38 | if (!repo.enableAutoRefresh) { 39 | continue 40 | } 41 | 42 | const branches: BranchSummary = await ipcRenderer.invoke( 43 | IPC_GET_BRANCHES, 44 | repo.repoId, 45 | ) 46 | 47 | if (branches) { 48 | for (const [name, branch] of Object.entries(branches.branches)) { 49 | if ( 50 | !['master', `remotes/${repo.remoteName}/master`].includes(branch.name) 51 | ) { 52 | newBranches.push({ 53 | isCheckedout: branch.current, 54 | name: name.replace(`remotes/${repo.remoteName}/`, ''), 55 | repoId: repo.repoId, 56 | orgID: repo.orgID, 57 | isRemote: name.startsWith('remotes/'), 58 | }) 59 | } 60 | } 61 | } else { 62 | logger.log('no branches for repo', repo) 63 | } 64 | } 65 | 66 | const oldRemoteBranches = state.branches.branches.filter( 67 | (branch) => branch.isRemote, 68 | ) 69 | 70 | const oldLocalBranches = state.branches.branches.filter( 71 | (branch) => !branch.isRemote, 72 | ) 73 | 74 | for (let i = 0; i < oldRemoteBranches.length; i++) { 75 | const newBranch = newBranches.find( 76 | (newBranch) => 77 | newBranch.name === oldRemoteBranches[i].name && newBranch.isRemote, 78 | ) 79 | 80 | if ( 81 | !newBranch && 82 | oldLocalBranches.find( 83 | (branch) => branch.name === oldRemoteBranches[i].name, 84 | ) 85 | ) { 86 | showNotification( 87 | { 88 | title: 'remote branch deleted, delete local?', 89 | body: oldRemoteBranches[i].name, 90 | }, 91 | false, 92 | async () => { 93 | const deleteResult = await ipcRenderer.invoke(IPC_DELETE_BRANCH, { 94 | repoId: oldRemoteBranches[i].repoId, 95 | branchName: oldRemoteBranches[i].name, 96 | isRemote: false, 97 | }) 98 | 99 | if (deleteResult) { 100 | await ipcRenderer.invoke( 101 | IPC_CHECKOUT_LOCAL_BRANCH, 102 | oldRemoteBranches[i].repoId, 103 | 'master', 104 | ) 105 | await ipcRenderer.invoke( 106 | IPC_PULL_BRANCH, 107 | oldRemoteBranches[i].repoId, 108 | ) 109 | } 110 | }, 111 | ) 112 | } 113 | } 114 | 115 | dispatch({ type: LOCAL_BRANCHES_UPDATED, payload: newBranches }) 116 | } 117 | -------------------------------------------------------------------------------- /src/shared/plugins/jira.ts: -------------------------------------------------------------------------------- 1 | import JiraClient from 'jira-connector' 2 | import { IJiraTicket } from '../types/tickets' 3 | // @ts-ignore 4 | import electronTimber from 'electron-timber' 5 | import { settingsPlugin } from '../../main/plugins/settings' 6 | import { getActiveSettings } from '../helpers' 7 | 8 | const logger = electronTimber.create({ name: 'PLUGIN:jira' }) 9 | 10 | // shared 11 | const _jiraClient = (jiraEmail: string, jiraAuth: string, jiraHost: string) => { 12 | const base64 = Buffer.from(`${jiraEmail}:${jiraAuth}`).toString('base64') 13 | 14 | // logger.log({ base64, email, auth }) 15 | 16 | return new JiraClient({ 17 | host: jiraHost, 18 | basic_auth: { 19 | base64, 20 | }, 21 | strictSSL: true, 22 | }) 23 | } 24 | 25 | // renderer 26 | const getMyTickets: () => Promise | false> = async () => { 27 | const { getRendererStore } = await import('../../renderer/store') 28 | 29 | const state = getRendererStore().getState() 30 | 31 | try { 32 | const activeSettings = getActiveSettings(state.settings) 33 | 34 | const result: { 35 | issues: Array 36 | } = await _jiraClient( 37 | activeSettings.jiraEmail, 38 | activeSettings.jiraAuth, 39 | activeSettings.jiraHost, 40 | ).search.search({ 41 | jql: activeSettings.jiraJQL, 42 | }) 43 | // result = await jira.dashboard.getAllDashboards({ startAt: 20 }) 44 | // result = await jira.avatar.getAvatars({ avatarType: 'project' }) 45 | const sortedIssues = result.issues.sort((issueA, issueB) => { 46 | const isAInProgress = issueA.fields.status.name 47 | .toLowerCase() 48 | .includes('progress') 49 | const isBInProgress = issueB.fields.status.name 50 | .toLowerCase() 51 | .includes('progress') 52 | 53 | if ( 54 | (isAInProgress && isBInProgress) || 55 | (!isAInProgress && !isBInProgress) 56 | ) { 57 | return 0 58 | } else if (isAInProgress && !isBInProgress) { 59 | return -1 60 | } else { 61 | return 1 62 | } 63 | }) 64 | return sortedIssues 65 | } catch (error) { 66 | logger.error('jira failed:', { error }) 67 | return false 68 | } 69 | } 70 | 71 | // main 72 | const branchNameFromTicketId = async (issueKey: string) => { 73 | let issue 74 | const { jiraEmail, jiraAuth, jiraHost } = getActiveSettings( 75 | settingsPlugin.getAll(), 76 | ) 77 | 78 | try { 79 | issue = await _jiraClient(jiraEmail, jiraAuth, jiraHost).issue.getIssue({ 80 | issueKey, 81 | }) 82 | // logger.log({ issue }) 83 | } catch (error) { 84 | logger.error({ error }) 85 | return 86 | } 87 | 88 | const rawTitle = issue.fields.summary 89 | 90 | const cleanTitle = rawTitle 91 | .toLowerCase() 92 | .replace(/ /g, '-') 93 | .replace(/[^a-z0-9-]*/g, '') 94 | .replace(/--/g, '-') 95 | .replace(/^-/, '') 96 | .replace(/-$/, '') 97 | .substr(0, 45) 98 | 99 | const branchTitle = `${issueKey.toLowerCase()}-${cleanTitle}` 100 | 101 | // logger.log({ rawTitle, cleanTitle, branchTitle }) 102 | 103 | return branchTitle 104 | // } catch (e) { 105 | // logger.log(`🔴 JSON error:`, e) 106 | // } 107 | } 108 | 109 | // renderer 110 | const ticketUrlFromKey = async (key: string) => { 111 | const { getRendererStore } = await import('../../renderer/store') 112 | 113 | const state = getRendererStore().getState() 114 | 115 | return `https://${getActiveSettings(state.settings).jiraHost}/browse/${key}` 116 | } 117 | 118 | export { branchNameFromTicketId, getMyTickets, ticketUrlFromKey } 119 | -------------------------------------------------------------------------------- /src/main/plugins/server.ts: -------------------------------------------------------------------------------- 1 | import { createBranch, createBranchFromTicketId } from './git' 2 | // todo: use main 3 | import { getPR } from '../../renderer/plugins/github' 4 | import { showNotification } from '../../shared/plugins/notifications' 5 | import bodyParser from 'body-parser' 6 | import express from 'express' 7 | import { okk } from '../helpers' 8 | // @ts-ignore 9 | import electronTimber from 'electron-timber' 10 | import { getRepoSettingsFromId } from '../../shared/helpers' 11 | 12 | const logger = electronTimber.create({ name: 'server' }) 13 | 14 | var app = express() 15 | 16 | const getTicketFromUrl = (url: string) => { 17 | const result = 18 | url.match(/selectedIssue=([^&]*)/) || 19 | url.match(/atlassian.net\/browse\/([^&?]*)/) 20 | 21 | if (result && result[1]) { 22 | return result[1] 23 | } else { 24 | logger.log('🔴 no tkt from url!') 25 | return false 26 | } 27 | } 28 | 29 | const parseGithubPRUrl = ( 30 | url: string, 31 | ): { org: string; repo: string; number: string } | false => { 32 | const match = url.match( 33 | /github\.com\/(?[^\/]*)\/(?[^\/]*)\/pull\/(?\d*)$/, 34 | ) 35 | 36 | const groups = match && match.groups 37 | 38 | if (groups) { 39 | return groups as { org: string; repo: string; number: string } 40 | } else { 41 | logger.log('🔴 no id from pr!') 42 | return false 43 | } 44 | } 45 | 46 | const startServer = () => { 47 | app.use(bodyParser.json()) 48 | app.use((_req, res, next) => { 49 | res.header( 50 | 'Access-Control-Allow-Origin', 51 | 'chrome-extension://dimancifnimncjkjnmomnhlopfmjkmng', 52 | ) 53 | res.header( 54 | 'Access-Control-Allow-Headers', 55 | 'Origin, X-Requested-With, Content-Type, Accept', 56 | ) 57 | next() 58 | }) 59 | 60 | app.post('/browserAction', async (req, res) => { 61 | res.sendStatus(200) 62 | 63 | const { 64 | body: { key, url }, 65 | } = req 66 | 67 | switch (key) { 68 | case 'createBranchFromTicket': 69 | // https://wkdauto.atlassian.net/secure/RapidBoard.jspa?rapidView=284&modal=detail&selectedIssue=REM-2245 70 | // https://wkdauto.atlassian.net/browse/REM-2245 71 | const ticketId = getTicketFromUrl(url) 72 | 73 | if (ticketId) { 74 | await createBranchFromTicketId(ticketId) 75 | } else { 76 | showNotification({ title: 'Failed to get ticket ID', body: '' }, true) 77 | } 78 | 79 | break 80 | case 'createBranchFromPR': 81 | const pr = parseGithubPRUrl(url) 82 | if (pr && pr.repo) { 83 | try { 84 | const pull = await getPR(pr.org, pr.repo, parseInt(pr.number)) 85 | 86 | const branchName = okk(pull.data.head.ref) 87 | okk(branchName) 88 | 89 | const repoSettings = await getRepoSettingsFromId(pr.repo) 90 | 91 | await createBranch( 92 | repoSettings.path, 93 | branchName, 94 | `remotes/${okk(repoSettings.remoteName)}/${branchName}`, 95 | ) 96 | 97 | showNotification( 98 | { title: 'Created branch', body: pull.data.title }, 99 | true, 100 | ) 101 | } catch (e) { 102 | logger.log('🛑server error:', e) 103 | showNotification({ title: 'Failed!', body: '' }, true) 104 | } 105 | } else { 106 | logger.log('🛑parseGithubPRUrl parse failed', url) 107 | } 108 | 109 | break 110 | 111 | default: 112 | throw new Error(`unknown key ${key}`) 113 | } 114 | }) 115 | 116 | // app.listen(okk(state.settings.port), () => 117 | // logger.log(` server listening on port ${state.settings.port}!`), 118 | // ) 119 | } 120 | 121 | export { startServer } 122 | -------------------------------------------------------------------------------- /src/main/plugins/timer.ts: -------------------------------------------------------------------------------- 1 | import { showNotification } from '../../shared/plugins/notifications' 2 | import { DiskSaver } from '../helpers/DiskSaver' 3 | import isWeekend from 'date-fns/isWeekend' 4 | 5 | type TimerState = { 6 | currentDate: string 7 | minutesCounter: number 8 | hoursCounter: number 9 | } 10 | 11 | class TimeTracker { 12 | checkIntervalMinutes = 1 13 | intervalID?: NodeJS.Timeout 14 | minutesCounter = 0 15 | hoursCounter = 0 16 | stateDiskSaver?: DiskSaver 17 | 18 | constructor() { 19 | this.resumeTicker() 20 | } 21 | 22 | resumeTicker() { 23 | this.loadState() 24 | this.pauseTicker() 25 | this.intervalID = setInterval(() => { 26 | this.tick() 27 | }, this.checkIntervalMinutes * 1000 * 60) 28 | } 29 | 30 | loadState() { 31 | const todaysDate = new Date().toISOString() 32 | 33 | const defaultState = { 34 | currentDate: todaysDate, 35 | minutesCounter: 0, 36 | hoursCounter: 0, 37 | } 38 | if (!this.stateDiskSaver) { 39 | this.stateDiskSaver = new DiskSaver({ 40 | configName: 'time-tracker', 41 | defaults: defaultState, 42 | }) 43 | } 44 | 45 | const savedState = this.stateDiskSaver.getAll() 46 | 47 | if (!this.isSameDay(savedState.currentDate, todaysDate)) { 48 | this.stateDiskSaver.save(defaultState) 49 | } 50 | } 51 | 52 | pauseTicker() { 53 | if (this.intervalID) { 54 | clearInterval(this.intervalID) 55 | } 56 | } 57 | 58 | tick() { 59 | const todaysDate = new Date() 60 | const todaysDateString = new Date().toISOString() 61 | 62 | if (isWeekend(todaysDate)) { 63 | return 64 | } 65 | 66 | if (this.stateDiskSaver) { 67 | let newState = this.stateDiskSaver.getAll() 68 | 69 | const defaultState = { 70 | currentDate: todaysDateString, 71 | minutesCounter: 0, 72 | hoursCounter: 0, 73 | } 74 | 75 | if (!this.isSameDay(todaysDateString, newState.currentDate)) { 76 | showNotification( 77 | { 78 | title: `It's a new working day, have fun!`, 79 | body: '', 80 | }, 81 | false, 82 | ) 83 | 84 | this.stateDiskSaver.save(defaultState) 85 | newState = defaultState 86 | } 87 | 88 | const currentHour = Math.floor(newState.minutesCounter / 60) 89 | 90 | if (currentHour > newState.hoursCounter) { 91 | newState.hoursCounter += 1 92 | 93 | if (newState.hoursCounter === 8) { 94 | showNotification( 95 | { 96 | title: `🎉 you made it, stop working NOW!`, 97 | body: '', 98 | }, 99 | false, 100 | ) 101 | } else if (newState.hoursCounter === 4) { 102 | showNotification( 103 | { 104 | title: `4 hours, time for a break!`, 105 | body: '', 106 | }, 107 | false, 108 | ) 109 | } else { 110 | showNotification( 111 | { 112 | title: `Nice! you spent ${newState.hoursCounter} hours working`, 113 | body: '', 114 | }, 115 | false, 116 | ) 117 | } 118 | } 119 | 120 | newState.minutesCounter = 121 | newState.minutesCounter + this.checkIntervalMinutes 122 | 123 | newState.currentDate = todaysDateString 124 | 125 | this.stateDiskSaver.save(newState) 126 | } else { 127 | throw new Error('should have state by now') 128 | } 129 | } 130 | 131 | onShutdown() { 132 | this.pauseTicker() 133 | } 134 | 135 | onLockScreen() { 136 | this.pauseTicker() 137 | } 138 | 139 | onUnlockScreen() { 140 | this.resumeTicker() 141 | } 142 | 143 | isSameDay(date1: string, date2: string) { 144 | const d1 = new Date(date1) 145 | const d2 = new Date(date2) 146 | 147 | return ( 148 | d1.getFullYear() === d2.getFullYear() && 149 | d1.getMonth() === d2.getMonth() && 150 | d1.getDate() === d2.getDate() 151 | ) 152 | } 153 | } 154 | 155 | export { TimeTracker } 156 | -------------------------------------------------------------------------------- /src/renderer/App.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | 3 | import { hot } from 'react-hot-loader/root' 4 | 5 | import React, { useEffect } from 'react' 6 | import { css, jsx } from '@emotion/react' 7 | 8 | import { fetchTickets, fetchPRs } from '../shared/store/tickets/actions' 9 | import { fetchGit } from '../shared/store/branches/actions' 10 | 11 | import { connect, ConnectedProps } from 'react-redux' 12 | import { TAppState } from '../main/store' 13 | import { Toolbar } from './components/Toolbar' 14 | import { TicketRow } from './components/TicketRow' 15 | import { RouteComponentProps } from 'react-router' 16 | import { BadgeStyle } from './components/styles' 17 | import { areSettingsValid } from '../shared/helpers' 18 | 19 | const mapState = (state: TAppState) => ({ 20 | tickets: state.tickets, 21 | settings: state.settings, 22 | branches: state.branches, 23 | }) 24 | 25 | const mapDispatch = { 26 | fetchTicketsAction: fetchTickets, 27 | fetchPRsAction: fetchPRs, 28 | fetchGitAction: fetchGit, 29 | } 30 | 31 | const connector = connect(mapState, mapDispatch) 32 | 33 | type TAppProps = ConnectedProps & RouteComponentProps<{}> 34 | 35 | const app: React.FC = ({ 36 | tickets: { isFetchingPRs, isFetchingTickets, pullRequests, ticketsData }, 37 | branches, 38 | settings, 39 | fetchTicketsAction, 40 | fetchPRsAction, 41 | fetchGitAction, 42 | history, 43 | }) => { 44 | const fetchData = (isFirstTime: boolean) => { 45 | if (areSettingsValid(settings)) { 46 | fetchTicketsAction(isFirstTime) 47 | fetchPRsAction(isFirstTime) 48 | fetchGitAction() 49 | } else { 50 | history.replace('/settings') 51 | } 52 | } 53 | 54 | useEffect(() => { 55 | // TODO: make it option 56 | const int = setInterval(fetchData, 1000 * 60 * 2) 57 | 58 | fetchData(true) 59 | 60 | return () => { 61 | if (int) { 62 | clearInterval(int) 63 | } 64 | } 65 | }, []) 66 | 67 | return ( 68 |
expandWidgetAction()} 70 | // onMouseLeave={e => collapseWidgetAction()} 71 | css={css` 72 | display: flex; 73 | flex-direction: column; 74 | padding: 5px; 75 | `} 76 | > 77 | fetchData(false)} 82 | /> 83 | 84 | {!pullRequests && !isFetchingPRs && ( 85 |
90 | Fetching PRs failed, please check your Github settings and internet 91 | connection! 92 |
93 | )} 94 | 95 | {!ticketsData && !isFetchingTickets && ( 96 |
101 | Fetching tickets failed, please check your Jira settings and internet 102 | connection! 103 |
104 | )} 105 | 106 | {ticketsData && 107 | ticketsData.map((ticketData) => { 108 | const relatedPRs = 109 | (pullRequests && 110 | pullRequests.filter((pullRequest) => 111 | pullRequest.head.ref 112 | .toLowerCase() 113 | .includes(ticketData.key.toLowerCase()), 114 | )) || 115 | [] 116 | 117 | const relatedBranches = 118 | branches.branches.filter((branch) => 119 | branch.name 120 | .toLowerCase() 121 | .startsWith(ticketData.key.toLowerCase()), 122 | ) || [] 123 | 124 | return ( 125 | fetchData(false)} 131 | /> 132 | ) 133 | })} 134 | 135 |
145 | Alt (Option) + z to show this window 146 |
147 |
148 | ) 149 | } 150 | 151 | const _app = connector(app) 152 | 153 | const App = hot(_app) 154 | 155 | export { App } 156 | -------------------------------------------------------------------------------- /src/renderer/plugins/github.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest' 2 | import { getRendererStore } from '../../renderer/store' 3 | import { 4 | TPullRequest, 5 | TExtendedPullRequest, 6 | CheckConclusion, 7 | } from '../../shared/types' 8 | import { getRepoSettingsFromId, getActiveSettings } from '../../shared/helpers' 9 | 10 | // renderer 11 | const updatePR = async (repoId: string, pullNumber: number) => { 12 | const state = getRendererStore().getState() 13 | 14 | const octokit = new Octokit({ 15 | auth: getActiveSettings(state.settings).githubAuth, 16 | }) 17 | 18 | const repoSettings = await getRepoSettingsFromId(repoId) 19 | 20 | octokit.pulls.updateBranch({ 21 | owner: repoSettings.orgID, 22 | repo: repoId, 23 | pull_number: pullNumber, 24 | headers: { 25 | 'If-None-Match': '', 26 | }, 27 | }) 28 | } 29 | 30 | // renderer 31 | const _getMyPRs = async (repoId: string, options = {}) => { 32 | const state = getRendererStore().getState() 33 | 34 | const octokit = new Octokit({ 35 | auth: getActiveSettings(state.settings).githubAuth, 36 | }) 37 | 38 | const repoSettings = await getRepoSettingsFromId(repoId) 39 | 40 | // todo: try to filter by user in options 41 | const { data: pulls } = await octokit.pulls.list({ 42 | repo: repoId, 43 | owner: repoSettings.orgID, 44 | sort: 'updated', 45 | direction: 'desc', 46 | per_page: 500, 47 | headers: { 48 | 'If-None-Match': '', 49 | }, 50 | ...options, 51 | }) 52 | 53 | return pulls.filter( 54 | (pull) => 55 | pull.user.login === getActiveSettings(state.settings).githubUserName, 56 | ) 57 | } 58 | 59 | // renderer 60 | const _extendPRs = async (repoId: string, pulls: TPullRequest) => { 61 | const state = getRendererStore().getState() 62 | const repoSettings = await getRepoSettingsFromId(repoId) 63 | 64 | const extendedPRs: Array = [] 65 | 66 | const octokit = new Octokit({ 67 | auth: getActiveSettings(state.settings).githubAuth, 68 | }) 69 | 70 | for (const pr of pulls) { 71 | const { data } = await getPR(pr.head.repo.owner.login, repoId, pr.number) 72 | 73 | const checks = await octokit.checks.listForRef({ 74 | owner: repoSettings.orgID, 75 | repo: repoId, 76 | ref: pr.head.ref, 77 | }) 78 | 79 | const checksStatus = checks.data.check_runs.every( 80 | (check) => check.conclusion === CheckConclusion.success, 81 | ) 82 | ? CheckConclusion.success 83 | : checks.data.check_runs.some( 84 | (check) => check.conclusion === CheckConclusion.failure, 85 | ) 86 | ? CheckConclusion.failure 87 | : CheckConclusion.neutral 88 | 89 | const dataWithChecks = { 90 | ...data, 91 | checksStatus, 92 | } 93 | 94 | extendedPRs.push(dataWithChecks) 95 | } 96 | 97 | return extendedPRs 98 | } 99 | 100 | // renderer 101 | const getMyExtendedPRs = async (repoId: string) => { 102 | const myPRs = await _getMyPRs(repoId) 103 | 104 | if (myPRs && myPRs.length) { 105 | return await _extendPRs(repoId, myPRs) 106 | } else { 107 | return [] 108 | } 109 | } 110 | 111 | // shared 112 | const getPR = async (owner: string, repo: string, prNumber: number) => { 113 | const state = getRendererStore().getState() 114 | 115 | const octokit = new Octokit({ 116 | auth: getActiveSettings(state.settings).githubAuth, 117 | }) 118 | 119 | const pull = await octokit.pulls.get({ 120 | owner, 121 | repo, 122 | pull_number: prNumber, 123 | headers: { 124 | 'If-None-Match': '', 125 | }, 126 | }) 127 | 128 | return pull 129 | } 130 | 131 | // renderer 132 | const generateNewOrCurrentPRLink = ({ 133 | orgID, 134 | repoId, 135 | branchName, 136 | }: { 137 | orgID: string 138 | repoId: string 139 | branchName: string 140 | }) => { 141 | const branchNameArray = branchName.split('-') 142 | 143 | const ticketID = `${branchNameArray 144 | .shift() 145 | ?.toUpperCase()}-${branchNameArray.shift()}` 146 | 147 | const state = getRendererStore().getState() 148 | 149 | const hasPR = 150 | state.tickets.pullRequests && 151 | state.tickets.pullRequests.find((pr) => 152 | pr.title.toLowerCase().includes(ticketID.toLowerCase()), 153 | ) 154 | 155 | if (hasPR) { 156 | return hasPR.html_url 157 | } else { 158 | return `https://github.com/${orgID}/${repoId}/compare/${branchName}?expand=1&title=${ticketID}: ${branchNameArray.join( 159 | ' ', 160 | )}` 161 | } 162 | } 163 | 164 | export { getPR, getMyExtendedPRs, updatePR, generateNewOrCurrentPRLink } 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Release](https://github.com/a7madgamal/katibu/workflows/Release/badge.svg?branch=master) 2 | 3 | # katibu 4 | 5 | - The ultimate tool for the super-lazy, 10x, extremely busy agile developer. 6 | - Connect Jira, git, Github in one place. How? check the table to know what you're missing 😱 7 | 8 | ## شرح للبرنامج باللغة العربية 9 | 10 | https://youtu.be/FXFyHAY3Br0 11 | 12 | ![image](https://user-images.githubusercontent.com/939602/77838115-449a8d80-7160-11ea-8fc1-38db1e777dfb.png) 13 | 14 | | before katibu 🐢 | after katibu 🚀 | 15 | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | 16 | | Someone creates a Jira ticket, you only know if he send it or assigns it to you | ![image](https://raw.githubusercontent.com/a7madgamal/katibu/master/media/1-notification.gif) katibu sends a notification that you can click to open the ticket. You can also open it by clicking on the ticket label in the UI | 17 | | you checkout master, pull, create a new local branch with the ticket number and title | ![image](https://raw.githubusercontent.com/a7madgamal/katibu/master/media/2-create-branch.gif) you click a button and katibu creates a new branch from latest master with the prefect git-friendly name. click the branch name to open this repo in vscode | 18 | | commit and push, usually many times | ![image](https://raw.githubusercontent.com/a7madgamal/katibu/master/media/3-push-branch.gif) click on the UP arrow to push (optionally with no-verify or force) | 19 | | you need to open a PR. you open githup, find the repo, you type the PR title and click open | ![image](https://raw.githubusercontent.com/a7madgamal/katibu/master/media/4-open-pr-github.gif) click on the github icon to open the current PR page OR a new PR page with a the perfect title, even if you push a single commit only ;) | 20 | | there are checks and they take LONG time and focus, then you have to wait for approvals before having a mergable PR. Oh no, master changed and you need to update your PR and run checks again 😡 | ![image](https://raw.githubusercontent.com/a7madgamal/katibu/master/media/5-pr-notification.gif) you get a notification when a new related PR is detected or when the status change (blocked or unblocked). | 21 | | you FINALLY merge your PR, you delete the remote branch, switch to master, remove the local branch (or worse, you dont!). wait, deleting failed so you try again with force | when a remote branch is deleted you get a notification to delete the local branch, you click it and it's gone (another one if force is needed) and BOOM! you're on updated master again ready to roll. | 22 | | you lose yourself in work and have a shitty work-life balance | you get an hourly reminder of how many hours left in your workday. you get a break reminder after 4 hours and one last reminder when you finish your 8 hours 🎉 it's counts screen-on time only | 23 | 24 | # IMPORTANT: because I can't sign my app (I need to pay Apple \$100 yearly 🤦🏻‍♂️) you need to do this: 25 | 26 | - download the latest version 27 | - extract, move the app to your applications folder 28 | - open the applications folder, right click the file `katibu`, press `option` key then click `open`, in the dialog you should be able to run, if not try again. this is needed only once after fresh downloads. 29 | 30 | ## download (still beta, please report any issues) 31 | 32 | - https://github.com/a7madgamal/katibu/releases 33 | 34 | ## development 35 | 36 | - run `yarn` then `yarn start` to test drive it. 37 | 38 | ## so, what does katibu means? 39 | 40 | - it means `secretary` in swahili. I'm actually from Egypt but I liked the word 🤷🏻‍♂️ 41 | -------------------------------------------------------------------------------- /src/shared/store/tickets/actions.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux' 2 | import { ThunkAction } from 'redux-thunk' 3 | 4 | import { getMyTickets, ticketUrlFromKey } from '../../plugins/jira' 5 | 6 | import { 7 | LOAD_JIRA_TICKETS, 8 | LOADING_JIRA_TICKETS_SUCCESS, 9 | LOADING_JIRA_TICKETS_FAIL, 10 | LOADING_PRS_SUCCESS, 11 | LOADING_PRS_FAIL, 12 | LOAD_PRS, 13 | IJiraTicket, 14 | } from '../../types/tickets' 15 | import { getMyExtendedPRs } from '../../../renderer/plugins/github' 16 | import { showNotification } from '../../plugins/notifications' 17 | import { shell } from 'electron' 18 | import { TExtendedPullRequest, CheckConclusion } from '../../types' 19 | // @ts-ignore 20 | import electronTimber from 'electron-timber' 21 | import { TAppState } from '../../../main/store' 22 | import { getActiveSettings } from '../../helpers' 23 | 24 | const logger = electronTimber.create({ name: 'tickets/actions' }) 25 | 26 | const getTicketById = (tickets: IJiraTicket[], id: string) => { 27 | for (let i = 0; i < tickets.length; i++) { 28 | if (tickets[i].id === id) { 29 | return tickets[i] 30 | } 31 | } 32 | 33 | return false 34 | } 35 | 36 | export const fetchTickets = ( 37 | isFirstTime: boolean, 38 | ): ThunkAction> => async ( 39 | dispatch, 40 | getState, 41 | ) => { 42 | const state = getState() 43 | const oldTickets = state.tickets.ticketsData 44 | 45 | dispatch({ type: LOAD_JIRA_TICKETS }) 46 | 47 | let newTickets: false | IJiraTicket[] 48 | 49 | try { 50 | newTickets = await getMyTickets() 51 | 52 | if (newTickets) { 53 | dispatch({ type: LOADING_JIRA_TICKETS_SUCCESS, payload: newTickets }) 54 | } else { 55 | dispatch({ type: LOADING_JIRA_TICKETS_FAIL }) 56 | } 57 | } catch (error) { 58 | logger.error('fetchTickets failed', error) 59 | 60 | dispatch({ type: LOADING_JIRA_TICKETS_FAIL }) 61 | 62 | return 63 | } 64 | 65 | if (!isFirstTime && Array.isArray(newTickets)) { 66 | for (let i = 0; i < newTickets.length; i++) { 67 | const newTicket = newTickets[i] 68 | const oldTicket = oldTickets && getTicketById(oldTickets, newTicket.id) 69 | 70 | if (oldTicket) { 71 | if (oldTicket.fields.status.name !== newTicket.fields.status.name) { 72 | showNotification( 73 | { 74 | title: newTicket.key, 75 | body: `Status changed to ${newTicket.fields.status.name}`, 76 | }, 77 | true, 78 | async () => 79 | shell.openExternal(await ticketUrlFromKey(newTicket.key)), 80 | ) 81 | } 82 | } else { 83 | showNotification( 84 | { 85 | title: newTicket.key, 86 | body: `New ticket detected!`, 87 | }, 88 | true, 89 | async () => shell.openExternal(await ticketUrlFromKey(newTicket.key)), 90 | ) 91 | } 92 | } 93 | } 94 | } 95 | 96 | export const fetchPRs = ( 97 | isFirstTime: boolean, 98 | ): ThunkAction> => async ( 99 | dispatch, 100 | getState, 101 | ) => { 102 | const state = getState() 103 | const profileSettings = getActiveSettings(state.settings) 104 | const oldPRs = state.tickets.pullRequests 105 | let allPRs: Array = [] 106 | 107 | dispatch({ type: LOAD_PRS }) 108 | 109 | try { 110 | for (const repo of profileSettings.reposList) { 111 | if (repo.enableAutoRefresh) { 112 | const pulls = await getMyExtendedPRs(repo.repoId) 113 | allPRs = [...allPRs, ...pulls] 114 | } 115 | } 116 | 117 | dispatch({ type: LOADING_PRS_SUCCESS, payload: allPRs }) 118 | 119 | if (!isFirstTime && oldPRs) { 120 | for (let i = 0; i < allPRs.length; i++) { 121 | const oldPR = oldPRs.find((oldPR) => oldPR.id == allPRs[i].id) 122 | 123 | if (oldPR) { 124 | if (oldPR.mergeable_state !== allPRs[i].mergeable_state) { 125 | showNotification( 126 | { 127 | title: allPRs[i].title, 128 | body: `PR state changed to ${allPRs[i].mergeable_state}`, 129 | }, 130 | true, 131 | () => shell.openExternal(allPRs[i].html_url), 132 | ) 133 | } 134 | 135 | if ( 136 | oldPR.checksStatus !== allPRs[i].checksStatus && 137 | allPRs[i].checksStatus === CheckConclusion.success 138 | ) { 139 | showNotification( 140 | { 141 | title: allPRs[i].title, 142 | body: 'PR checks are ✅ green', 143 | }, 144 | true, 145 | () => shell.openExternal(allPRs[i].html_url), 146 | ) 147 | } 148 | 149 | if ( 150 | oldPR.checksStatus !== allPRs[i].checksStatus && 151 | allPRs[i].checksStatus === CheckConclusion.failure 152 | ) { 153 | showNotification( 154 | { 155 | title: allPRs[i].title, 156 | body: 'PR checks are 🔴 red', 157 | }, 158 | false, 159 | () => shell.openExternal(allPRs[i].html_url), 160 | ) 161 | } 162 | } else { 163 | showNotification( 164 | { 165 | title: 'new PR detected', 166 | body: allPRs[i].title, 167 | }, 168 | true, 169 | () => shell.openExternal(allPRs[i].html_url), 170 | ) 171 | } 172 | } 173 | } 174 | } catch (error) { 175 | logger.error(error) 176 | dispatch({ type: LOADING_PRS_FAIL }) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | prepare_release: 8 | name: Release 9 | outputs: 10 | commit_id: ${{ steps.release_commit.outputs.commit_id }} 11 | upload_url: ${{ steps.create_release.outputs.upload_url }} 12 | runs-on: 'ubuntu-latest' 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v1 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: 12 22 | 23 | - name: Install dependencies 24 | run: yarn install --frozen-lockfile 25 | 26 | - name: Create release commit 27 | id: release_commit 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | run: | 31 | git rev-parse HEAD 32 | yarn release 33 | git rev-parse HEAD 34 | echo "COMMIT_ID=`git rev-parse HEAD`" >> $GITHUB_ENV 35 | echo "LATEST_TAG=`git describe --tags --abbrev=0`" >> $GITHUB_ENV 36 | echo "::set-output name=commit_id::$release_commit" 37 | 38 | - name: Create GH Release 39 | id: create_release 40 | uses: actions/create-release@v1 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | with: 44 | tag_name: ${{ env.LATEST_TAG }} 45 | release_name: ${{ env.LATEST_TAG }} 46 | draft: true 47 | prerelease: true 48 | 49 | release_ubuntu: 50 | name: Release (Ubuntu) 51 | runs-on: 'ubuntu-latest' 52 | needs: [prepare_release] 53 | steps: 54 | - name: Checkout Latest 55 | uses: actions/checkout@v1 56 | 57 | - name: Package (Ubuntu) 58 | run: | 59 | yarn install --frozen-lockfile 60 | yarn make 61 | ls -R out/ 62 | tar -zcvf katibu-linux-x64.tar.gz out/katibu-linux-x64 63 | echo "DEB_ASSET_NAME=`find out/make/deb/x64/ -name "*.deb" -exec basename {} \;`" >> $GITHUB_ENV 64 | echo "RPM_ASSET_NAME=`find out/make/rpm/x64/ -name "*.rpm" -exec basename {} \;`" >> $GITHUB_ENV 65 | 66 | - name: Upload Release Asset (Ubuntu - deb) 67 | uses: actions/upload-release-asset@v1 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | with: 71 | upload_url: ${{ needs.prepare_release.outputs.upload_url }} 72 | asset_path: './out/make/deb/x64/${{ env.DEB_ASSET_NAME }}' 73 | asset_name: ${{ env.DEB_ASSET_NAME }} 74 | asset_content_type: application/vnd.debian.binary-package 75 | 76 | - name: Upload Release Asset (Ubuntu - raw) 77 | uses: actions/upload-release-asset@v1 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | with: 81 | upload_url: ${{ needs.prepare_release.outputs.upload_url }} 82 | asset_path: './katibu-linux-x64.tar.gz' 83 | asset_name: katibu-linux-x64.tar.gz 84 | asset_content_type: application/gzip 85 | 86 | - name: Upload Release Asset (Ubuntu - rpm) 87 | uses: actions/upload-release-asset@v1 88 | env: 89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | with: 91 | upload_url: ${{ needs.prepare_release.outputs.upload_url }} 92 | asset_path: './out/make/rpm/x64/${{ env.RPM_ASSET_NAME }}' 93 | asset_name: ${{ env.RPM_ASSET_NAME }} 94 | asset_content_type: application/x-rpm 95 | 96 | release_macos: 97 | name: Release (MacOS) 98 | runs-on: 'macos-latest' 99 | needs: [prepare_release] 100 | steps: 101 | - name: Checkout Latest 102 | uses: actions/checkout@v1 103 | 104 | - name: Package (MacOS) 105 | run: | 106 | yarn install --frozen-lockfile 107 | yarn make 108 | ls -R out/make 109 | echo "ZIP_ASSET_NAME=`find out/make/zip/darwin/x64/ -name "*.zip" -exec basename {} \;`" >> $GITHUB_ENV 110 | 111 | - name: Upload Release Asset (MacOS - zip) 112 | uses: actions/upload-release-asset@v1 113 | env: 114 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 115 | with: 116 | upload_url: ${{ needs.prepare_release.outputs.upload_url }} 117 | asset_path: './out/make/zip/darwin/x64/${{ env.ZIP_ASSET_NAME }}' 118 | asset_name: ${{ env.ZIP_ASSET_NAME }} 119 | asset_content_type: application/zip 120 | 121 | release_win: 122 | name: Release (Win) 123 | runs-on: 'windows-latest' 124 | needs: [prepare_release] 125 | steps: 126 | - name: Checkout Latest 127 | uses: actions/checkout@v1 128 | 129 | - name: Package (Win) 130 | run: | 131 | yarn install --frozen-lockfile 132 | curl -OLS https://github.com/wixtoolset/wix3/releases/download/wix3111rtm/wix311.exe 133 | .\wix311.exe /install /quiet /norestart 134 | $wixPath='"' + "C:\Program Files (x86)\WiX Toolset v3.11\bin\" +'"' 135 | $ENV:PATH="$ENV:PATH;$wixPath" 136 | yarn make 137 | ls -R out/make 138 | $result=Get-ChildItem -Path out\make\wix\x64 -include *.msi -Name 139 | echo "MSI_ASSET_NAME=$result" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append 140 | 141 | - name: Upload Release Asset (Win - zip) 142 | uses: actions/upload-release-asset@v1 143 | env: 144 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 145 | with: 146 | upload_url: ${{ needs.prepare_release.outputs.upload_url }} 147 | asset_path: './out/make/wix/x64/${{ env.MSI_ASSET_NAME }}' 148 | asset_name: ${{ env.MSI_ASSET_NAME }} 149 | asset_content_type: application/octet-stream 150 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "katibu", 3 | "private": true, 4 | "productName": "katibu", 5 | "version": "1.5.0", 6 | "description": "THE helper tool you need for modern development", 7 | "repository": "https://github.com/a7madgamal/katibu", 8 | "main": ".webpack/main", 9 | "scripts": { 10 | "start": "yarn tsc && electron-forge start", 11 | "package": "electron-forge package", 12 | "make": "electron-forge make", 13 | "release": "semantic-release", 14 | "lint:css": "stylelint './src/**/*.tsx'", 15 | "tsc": "tsc --noEmit", 16 | "prettier": "prettier --write src" 17 | }, 18 | "keywords": [ 19 | "git", 20 | "jira", 21 | "github", 22 | "productivity", 23 | "electron" 24 | ], 25 | "author": { 26 | "name": "Ahmed Hassanein", 27 | "email": "eng.a7mad.gamal@gmail.com" 28 | }, 29 | "license": "MIT", 30 | "config": { 31 | "forge": { 32 | "packagerConfig": { 33 | "extendInfo": "Info.plist" 34 | }, 35 | "publishers": [ 36 | { 37 | "name": "@electron-forge/publisher-github", 38 | "config": { 39 | "repository": { 40 | "owner": "a7madgamal", 41 | "name": "katibu" 42 | }, 43 | "prerelease": true 44 | } 45 | } 46 | ], 47 | "makers": [ 48 | { 49 | "name": "@electron-forge/maker-wix", 50 | "config": { 51 | "language": 1033, 52 | "manufacturer": "Ahmed Hassanein" 53 | } 54 | }, 55 | { 56 | "name": "@electron-forge/maker-zip", 57 | "platforms": [ 58 | "darwin" 59 | ] 60 | }, 61 | { 62 | "name": "@electron-forge/maker-deb", 63 | "config": {} 64 | }, 65 | { 66 | "name": "@electron-forge/maker-rpm", 67 | "config": {} 68 | } 69 | ], 70 | "plugins": [ 71 | [ 72 | "@electron-forge/plugin-webpack", 73 | { 74 | "port": 9898, 75 | "loggerPort": 1234, 76 | "mainConfig": "./webpack.main.config.js", 77 | "renderer": { 78 | "config": "./webpack.renderer.config.js", 79 | "entryPoints": [ 80 | { 81 | "html": "./src/renderer/index.html", 82 | "js": "./src/renderer/index.tsx", 83 | "name": "main_window" 84 | } 85 | ] 86 | } 87 | } 88 | ] 89 | ] 90 | } 91 | }, 92 | "dependencies": { 93 | "@emotion/babel-preset-css-prop": "^11.0.0", 94 | "@emotion/react": "^11.1.1", 95 | "@emotion/styled": "^11.0.0", 96 | "@fortawesome/fontawesome-svg-core": "^1.2.32", 97 | "@fortawesome/free-brands-svg-icons": "^5.15.1", 98 | "@fortawesome/free-solid-svg-icons": "^5.15.1", 99 | "@fortawesome/react-fontawesome": "^0.1.13", 100 | "@octokit/rest": "^18.0.9", 101 | "babel-plugin-transform-react-jsx-source": "^6.22.0", 102 | "body-parser": "^1.19.0", 103 | "compare-versions": "^3.6.0", 104 | "date-fns": "^2.17.0", 105 | "devtron": "^1.4.0", 106 | "electron-redux": "^1.5.4", 107 | "electron-squirrel-startup": "^1.0.0", 108 | "electron-unhandled": "^3.0.2", 109 | "express": "4.17.1", 110 | "file-loader": "^6.2.0", 111 | "final-form": "^4.20.1", 112 | "final-form-arrays": "^3.0.2", 113 | "got": "^11.8.0", 114 | "history": "^4.10.1", 115 | "jira-connector": "^3.1.0", 116 | "node-jenkins": "^1.2.5", 117 | "react": "^17.0.1", 118 | "react-dom": "^17.0.1", 119 | "react-final-form": "^6.5.2", 120 | "react-final-form-arrays": "^3.1.3", 121 | "react-hot-loader": "^4.13.0", 122 | "react-redux": "^7.2.2", 123 | "react-router": "^5.2.0", 124 | "react-router-dom": "^5.2.0", 125 | "redux": "^4.0.5", 126 | "redux-thunk": "^2.3.0", 127 | "simple-git": "^2.23.0", 128 | "source-map-support": "^0.5.19", 129 | "ts-loader": "^8.0.11", 130 | "universal-user-agent": "6.0.0" 131 | }, 132 | "devDependencies": { 133 | "@electron-forge/cli": "6.0.0-beta.54", 134 | "@electron-forge/maker-deb": "6.0.0-beta.54", 135 | "@electron-forge/maker-rpm": "6.0.0-beta.54", 136 | "@electron-forge/maker-squirrel": "6.0.0-beta.54", 137 | "@electron-forge/maker-wix": "6.0.0-beta.54", 138 | "@electron-forge/maker-zip": "6.0.0-beta.54", 139 | "@electron-forge/plugin-webpack": "6.0.0-beta.54", 140 | "@electron-forge/publisher-github": "6.0.0-beta.54", 141 | "@emotion/babel-plugin": "^11.0.0", 142 | "@marshallofsound/webpack-asset-relocator-loader": "^0.5.0", 143 | "@semantic-release/changelog": "^5.0.1", 144 | "@semantic-release/commit-analyzer": "^8.0.1", 145 | "@semantic-release/git": "^9.0.0", 146 | "@semantic-release/github": "^7.2.0", 147 | "@types/body-parser": "^1.19.0", 148 | "@types/electron-devtools-installer": "^2.2.0", 149 | "@types/express": "4.17.9", 150 | "@types/react-dom": "^17.0.0", 151 | "@types/react-redux": "^7.1.11", 152 | "@types/react-router": "^5.1.8", 153 | "@types/react-router-dom": "^5.1.6", 154 | "@types/request": "^2.48.5", 155 | "@types/source-map-support": "^0.5.3", 156 | "@types/vfile-message": "^2.0.0", 157 | "conventional-changelog-conventionalcommits": "^4.5.0", 158 | "css-loader": "^5.0.1", 159 | "electron": "11.0.3", 160 | "electron-devtools-installer": "^3.1.1", 161 | "electron-timber": "^0.5.1", 162 | "electron-wix-msi": "^3.0.4", 163 | "node-loader": "^1.0.2", 164 | "prettier": "^2.2.0", 165 | "redux-devtools-extension": "^2.13.8", 166 | "semantic-release": "^17.3.0", 167 | "style-loader": "^2.0.0", 168 | "stylelint": "^13.8.0", 169 | "stylelint-config-recommended": "^3.0.0", 170 | "stylelint-config-standard": "^20.0.0", 171 | "type-fest": "^0.20.1", 172 | "typescript": "^4.1.2" 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | const MAIN_WINDOW_WEBPACK_ENTRY: string 3 | } 4 | 5 | import sourceMapSupport from 'source-map-support' 6 | sourceMapSupport.install() 7 | 8 | import electronUnhandled from 'electron-unhandled' 9 | 10 | electronUnhandled({ showDialog: true }) 11 | 12 | import { 13 | app, 14 | globalShortcut, 15 | ipcMain, 16 | BrowserWindow, 17 | powerMonitor, 18 | } from 'electron' 19 | 20 | // import { setContextMenu } from '../plugins/tray' 21 | import { settingsPlugin } from './plugins/settings' 22 | import { createAppWindow, createSelectWindow } from './plugins/windows' 23 | import { 24 | getRepoFromPath, 25 | getRemote, 26 | createBranchFromTicketId, 27 | deleteBranch, 28 | rebaseLocalBranch, 29 | checkoutLocalBranch, 30 | getBranches, 31 | pullActiveBranch, 32 | } from './plugins/git' 33 | import { showNotification } from '../shared/plugins/notifications' 34 | import { pushTask } from './tasks/push' 35 | // @ts-ignore 36 | import electronTimber from 'electron-timber' 37 | import { updateChecker } from './plugins/updateChecker' 38 | 39 | import { 40 | IPC_CHECKOUT_LOCAL_BRANCH, 41 | IPC_CREATE_BRANCH, 42 | IPC_REBASE_BRANCH, 43 | IPC_DELETE_BRANCH, 44 | IPC_PUSH_BRANCH, 45 | IPC_CANCEL_SELECT, 46 | IPC_HIDE_SELECT, 47 | IPC_RENDER_REFRESH_TICKETS, 48 | IPC_RENDER_REFRESH_GIT, 49 | IPC_RENDER_REFRESH_PRS, 50 | IPC_RENDER_NAVIGATE_HOME, 51 | IPC_GET_GIT_REMOTE, 52 | IPC_RELOAD, 53 | IPC_LOAD_SETTINGS, 54 | IPC_GET_BRANCHES, 55 | IPC_SAVE_SETTINGS, 56 | IPC_PULL_BRANCH, 57 | INITIAL_PROFILE, 58 | } from '../shared/constants' 59 | import { getMainStore } from './store' 60 | import { LOAD_SETTINGS } from '../shared/types/settings' 61 | import { TimeTracker } from './plugins/timer' 62 | import { getProfileSettings } from '../shared/helpers' 63 | 64 | const logger = electronTimber.create({ name: 'index' }) 65 | 66 | // todo: pay $100 for greedy apple to sign the app -.- 67 | // require('update-electron-app')() 68 | 69 | var mainWindow: BrowserWindow 70 | var selectWindow: BrowserWindow 71 | 72 | app.on('ready', () => { 73 | // electronDevtoolsInstaller(REACT_DEVELOPER_TOOLS) 74 | // .then(name => logger.log(`REACT_DEVELOPER_TOOLS Added: ${name}`)) 75 | // .catch(err => logger.error('REACT_DEVELOPER_TOOLS error:', err)) 76 | const store = getMainStore() 77 | const payload = settingsPlugin.getAll() 78 | 79 | // make sure we get any new defaults 80 | payload.profiles = payload.profiles.map((profile) => { 81 | return { 82 | ...INITIAL_PROFILE, 83 | ...profile, 84 | } 85 | }) 86 | 87 | store.dispatch({ type: LOAD_SETTINGS, payload }) 88 | 89 | mainWindow = createAppWindow() 90 | selectWindow = createSelectWindow() 91 | // setContextMenu() 92 | if (getProfileSettings(payload, payload.activeProfile).isTimeTrackerEnabled) { 93 | var timeTracker = new TimeTracker() 94 | 95 | powerMonitor.on('shutdown', () => { 96 | console.log('powerMonitor shutdown') 97 | timeTracker.onShutdown() 98 | }) 99 | 100 | powerMonitor.on('lock-screen', () => { 101 | console.log('powerMonitor lock-screen') 102 | timeTracker.onLockScreen() 103 | }) 104 | powerMonitor.on('unlock-screen', () => { 105 | console.log('powerMonitor unlock-screen') 106 | timeTracker.onUnlockScreen() 107 | }) 108 | } 109 | 110 | // todo: enable with port 111 | // try { 112 | // startServer() 113 | // } catch (error) { 114 | // logger.error('startServer failed!', error) 115 | // } 116 | 117 | try { 118 | registerShortcuts() 119 | } catch (error) { 120 | logger.error('registerShortcuts failed!', error) 121 | } 122 | 123 | updateChecker() 124 | 125 | // getInfo() 126 | }) 127 | 128 | app.on('browser-window-focus', (_e) => { 129 | // const creationTime = process.getCreationTime() 130 | // const now = new Date().getTime() 131 | // const diff = creationTime ? now - Math.round(creationTime) : true 132 | 133 | // todo: check if settings ready 134 | mainWindow.webContents.send(IPC_RENDER_REFRESH_TICKETS) 135 | mainWindow.webContents.send(IPC_RENDER_REFRESH_GIT) 136 | mainWindow.webContents.send(IPC_RENDER_REFRESH_PRS) 137 | }) 138 | 139 | function registerShortcuts() { 140 | // okk( 141 | globalShortcut.register('Alt+z', () => { 142 | mainWindow.show() 143 | }) 144 | // ) 145 | } 146 | 147 | ipcMain.handle(IPC_SAVE_SETTINGS, async (_e, settings) => { 148 | settingsPlugin.save(settings) 149 | 150 | return true 151 | }) 152 | 153 | ipcMain.handle(IPC_GET_BRANCHES, async (_e, repoId: string) => { 154 | const branches = await getBranches(repoId) 155 | 156 | return branches 157 | }) 158 | 159 | ipcMain.handle(IPC_GET_GIT_REMOTE, async (_e, path: string) => { 160 | const gitRepo = await getRepoFromPath(path) 161 | 162 | if (gitRepo) { 163 | const remote = await getRemote(gitRepo) 164 | 165 | return remote 166 | } else { 167 | return false 168 | } 169 | }) 170 | 171 | ipcMain.handle(IPC_LOAD_SETTINGS, async (_e) => { 172 | const settings = settingsPlugin.getAll() 173 | 174 | return settings 175 | }) 176 | 177 | ipcMain.handle(IPC_CREATE_BRANCH, async (_e, key: string) => { 178 | const result = await createBranchFromTicketId(key) 179 | 180 | if (result) { 181 | showNotification({ 182 | title: 'branch created', 183 | body: key, 184 | }) 185 | 186 | mainWindow.webContents.send(IPC_RENDER_REFRESH_GIT) 187 | } 188 | }) 189 | 190 | ipcMain.handle( 191 | IPC_DELETE_BRANCH, 192 | async (_e, { repoId, branchName, isRemote }) => { 193 | const result = await deleteBranch(repoId, branchName, isRemote, false) 194 | 195 | mainWindow.webContents.send(IPC_RENDER_REFRESH_PRS) 196 | mainWindow.webContents.send(IPC_RENDER_REFRESH_GIT) 197 | return result 198 | }, 199 | ) 200 | 201 | ipcMain.handle(IPC_REBASE_BRANCH, async (_e, repoId, branchName) => { 202 | try { 203 | await rebaseLocalBranch(repoId, branchName) 204 | showNotification({ 205 | title: 'branch rebased', 206 | body: `${repoId}:${branchName}`, 207 | }) 208 | } catch (error) { 209 | showNotification({ 210 | title: 'branch rebase failed', 211 | body: `${repoId}:${branchName}`, 212 | }) 213 | } 214 | 215 | mainWindow.webContents.send(IPC_RENDER_REFRESH_GIT) 216 | }) 217 | 218 | ipcMain.handle( 219 | IPC_PUSH_BRANCH, 220 | async ( 221 | _e, 222 | { repoId, skipChecks, branchName }: Parameters[0], 223 | ) => { 224 | await pushTask({ repoId, skipChecks, branchName }) 225 | mainWindow.webContents.send(IPC_RENDER_REFRESH_GIT) 226 | }, 227 | ) 228 | 229 | ipcMain.handle(IPC_PULL_BRANCH, async (_e, repoId) => { 230 | await pullActiveBranch(repoId) 231 | }) 232 | 233 | ipcMain.handle(IPC_CHECKOUT_LOCAL_BRANCH, async (_e, repoId, branchName) => { 234 | const success = await checkoutLocalBranch(repoId, branchName) 235 | 236 | if (success) { 237 | showNotification({ 238 | title: 'checked out branch', 239 | body: `${repoId}:${branchName}`, 240 | }) 241 | } else { 242 | showNotification({ 243 | title: 'failed to checkout branch', 244 | body: `${repoId}:${branchName}`, 245 | }) 246 | } 247 | 248 | mainWindow.webContents.send(IPC_RENDER_REFRESH_GIT) 249 | }) 250 | 251 | ipcMain.on(IPC_HIDE_SELECT, () => { 252 | selectWindow.hide() 253 | }) 254 | 255 | ipcMain.handle(IPC_CANCEL_SELECT, () => { 256 | selectWindow.hide() 257 | }) 258 | 259 | ipcMain.handle(IPC_RELOAD, () => { 260 | selectWindow.reload() 261 | mainWindow.webContents.send(IPC_RENDER_NAVIGATE_HOME) 262 | }) 263 | 264 | // app.on('window-all-closed', () => { 265 | // if (process.platform !== 'darwin') app.quit() 266 | // }) 267 | 268 | // app.on('activate', function() { 269 | // if (mainWindow === null) createAppWindow() 270 | // }) 271 | 272 | app.on('will-quit', () => { 273 | globalShortcut.unregisterAll() 274 | 275 | // for (const repoPath of settings.get('reposList')) { 276 | // clearInterval(githubInterval[repoPath]) 277 | // } 278 | 279 | // expressServer && expressServer.close && expressServer.close() 280 | }) 281 | 282 | process.on('unhandledRejection', (error) => { 283 | logger.error('🔴 unhandledRejection', error) 284 | app.quit() 285 | }) 286 | 287 | process.on('uncaughtException', (error) => { 288 | logger.error('🔴 uncaughtException', error) 289 | app.quit() 290 | }) 291 | 292 | // Handle creating/removing shortcuts on Windows when installing/uninstalling. 293 | // if (require('electron-squirrel-startup')) { // eslint-disable-line global-require 294 | // app.quit(); 295 | // } 296 | 297 | // app.on('window-all-closed', () => { 298 | // if (process.platform !== 'darwin') { 299 | // app.quit() 300 | // } 301 | // }) 302 | 303 | // app.on('activate', () => { 304 | // if (mainWindow === null) { 305 | // createAppWindow() 306 | // } 307 | // }) 308 | // export { mainWindow, selectWindow } 309 | 310 | // powerMonitor.on('suspend', () => { 311 | // console.log('powerMonitor suspend') 312 | // }) 313 | // powerMonitor.on('resume', () => { 314 | // console.log('powerMonitor resume') 315 | // }) 316 | -------------------------------------------------------------------------------- /src/main/plugins/git.ts: -------------------------------------------------------------------------------- 1 | import * as git from 'simple-git/promise' 2 | 3 | import { okk } from '../helpers' 4 | import { getRepoSettingsFromId } from '../../shared/helpers' 5 | import { showRepoSelector } from './windows' 6 | import { branchNameFromTicketId } from '../../shared/plugins/jira' 7 | import { showNotification } from '../../shared/plugins/notifications' 8 | import { IRepoSetting } from '../../shared/types/settings' 9 | // @ts-ignore 10 | import electronTimber from 'electron-timber' 11 | import { TRepoRemote } from '../../shared/types' 12 | 13 | const logger = electronTimber.create({ name: 'git' }) 14 | 15 | const _getGitRepoFromId = async (repoId: string) => { 16 | const repoSettings = await getRepoSettingsFromId(repoId) 17 | 18 | const gitRepo = git(okk(repoSettings.path)) 19 | return gitRepo 20 | } 21 | 22 | const _getRepoStatus = async (repoPath: string) => { 23 | const repo = git(okk(repoPath)) 24 | const status = await repo.status() 25 | return status 26 | } 27 | 28 | const deleteBranch = async ( 29 | repoId: string, 30 | branchName: string, 31 | isRemote: boolean, 32 | force: boolean, 33 | ): Promise => { 34 | return new Promise(async (resolve, _reject) => { 35 | const repoSettings = await getRepoSettingsFromId(repoId) 36 | 37 | const gitRepo = await _getGitRepoFromId(repoId) 38 | 39 | if (isRemote) { 40 | try { 41 | const notification = showNotification({ 42 | title: 'deleting remote branch...', 43 | body: `${repoId}:${branchName}`, 44 | }) 45 | 46 | await gitRepo.push(okk(repoSettings.remoteName), branchName, { 47 | '--delete': null, 48 | }) 49 | 50 | notification.close() 51 | 52 | showNotification( 53 | { 54 | title: 'remote branch deleted 👍🏻', 55 | body: `${repoId}:${branchName}`, 56 | }, 57 | true, 58 | ) 59 | 60 | resolve(true) 61 | } catch (error) { 62 | showNotification({ 63 | title: "failed: couldn't delete remote 👎🏻", 64 | body: `${repoId}:${branchName}`, 65 | }) 66 | resolve(false) 67 | } 68 | return 69 | } 70 | 71 | try { 72 | const status = await _getRepoStatus(okk(repoSettings.path)) 73 | 74 | if (status.current === branchName) { 75 | await gitRepo.checkout('master') 76 | } 77 | } catch (error) { 78 | showNotification({ 79 | title: 'failed to checkout master!', 80 | body: `${repoId}:${branchName}`, 81 | }) 82 | resolve(false) 83 | } 84 | 85 | if (force) { 86 | try { 87 | await gitRepo.raw(['branch', '-D', branchName]) 88 | 89 | showNotification({ 90 | title: 'force deleted 💪🏻', 91 | body: `${repoId}:${branchName}`, 92 | }) 93 | 94 | resolve(true) 95 | } catch (error) { 96 | logger.error('force delete failed') 97 | showNotification({ 98 | title: 'even force delete failed 😅', 99 | body: `${repoId}:${branchName}`, 100 | }) 101 | 102 | resolve(false) 103 | } 104 | } else { 105 | try { 106 | const deleteResult = await gitRepo.deleteLocalBranch(branchName) 107 | 108 | if (deleteResult.success) { 109 | showNotification({ 110 | title: 'branch deleted 👍🏻', 111 | body: `${repoId}:${branchName}`, 112 | }) 113 | resolve(true) 114 | } else { 115 | showNotification( 116 | { 117 | title: "couldn't delete, force?", 118 | body: `${repoId}:${branchName}`, 119 | }, 120 | false, 121 | async () => { 122 | const result = await deleteBranch( 123 | repoId, 124 | branchName, 125 | isRemote, 126 | true, 127 | ) 128 | resolve(result) 129 | }, 130 | ) 131 | } 132 | } catch (error) { 133 | logger.error('delete caused an error', error) 134 | showNotification( 135 | { 136 | title: 'something went wrong, force?', 137 | body: `${repoId}:${branchName}`, 138 | }, 139 | false, 140 | async () => { 141 | const result = await deleteBranch( 142 | repoId, 143 | branchName, 144 | isRemote, 145 | true, 146 | ) 147 | 148 | resolve(result) 149 | }, 150 | ) 151 | } 152 | } 153 | }) 154 | } 155 | 156 | const pullActiveBranch = async (repoId: string) => { 157 | const repoSettings = await getRepoSettingsFromId(repoId) 158 | const gitRepo = await _getGitRepoFromId(repoId) 159 | 160 | const result = await gitRepo.pull(okk(repoSettings.remoteName)) 161 | 162 | return result 163 | } 164 | 165 | const getBranches = async (repoId: string) => { 166 | const repoSettings = await getRepoSettingsFromId(repoId) 167 | const gitRepo = await _getGitRepoFromId(repoId) 168 | 169 | try { 170 | await gitRepo.fetch(okk(repoSettings.remoteName), { 171 | '--prune': null, 172 | }) 173 | } catch (error) { 174 | logger.error('getBranches fetch failed', error) 175 | } 176 | 177 | const branches = await gitRepo.branch() 178 | 179 | return branches 180 | } 181 | 182 | const createBranch = async ( 183 | repoPath: string, 184 | title: string, 185 | fromBranch = 'master', 186 | ) => { 187 | const repo = git(okk(repoPath)) 188 | 189 | await repo.checkout('master') 190 | 191 | try { 192 | await repo.pull() 193 | } catch (error) { 194 | logger.log('pull failed', error) 195 | } 196 | console.log({ title, fromBranch }) 197 | 198 | try { 199 | await repo.checkoutBranch(okk(title), fromBranch) 200 | } catch (error) { 201 | logger.log('checkoutBranch failed', error) 202 | } 203 | } 204 | 205 | const pushBranch = async ({ 206 | repo, 207 | skipChecks = false, 208 | forcePush = false, 209 | branchName, 210 | }: { 211 | repo: IRepoSetting 212 | skipChecks?: boolean 213 | forcePush?: boolean 214 | branchName?: string 215 | }) => { 216 | const gitRepo = git(okk(repo.path)) 217 | 218 | const status = await _getRepoStatus(repo.path) 219 | const selectedBranchName = 220 | branchName || (status.current === null ? undefined : status.current) 221 | 222 | try { 223 | await gitRepo.push(okk(repo.remoteName), selectedBranchName, { 224 | ...(skipChecks ? { '--no-verify': null } : {}), 225 | ...(forcePush ? { '-f': null } : {}), 226 | }) 227 | } catch (error) { 228 | logger.error('push error', error) 229 | return false 230 | } 231 | 232 | return okk(selectedBranchName) 233 | } 234 | 235 | const createBranchFromTicketId = async (ticketId: string) => { 236 | try { 237 | const newBranchName = await branchNameFromTicketId(ticketId) 238 | const settings = await showRepoSelector() 239 | 240 | if (!settings) { 241 | return false 242 | } 243 | 244 | await createBranch(okk(settings.path), okk(newBranchName)) 245 | 246 | return true 247 | } catch (e) { 248 | logger.error('createBranchFromTicket:', e) 249 | 250 | showNotification( 251 | { 252 | title: 'failed to create branch.', 253 | body: 'make sure you committed all open changes', 254 | }, 255 | true, 256 | ) 257 | return false 258 | } 259 | } 260 | 261 | const rebaseLocalBranch = async (repoId: string, branchName: string) => { 262 | const gitRepo = await _getGitRepoFromId(repoId) 263 | 264 | await gitRepo.checkout('master') 265 | const pullResult = await gitRepo.pull() 266 | await gitRepo.checkout(branchName) 267 | const rebaseResult = gitRepo.rebase(['master']) 268 | 269 | logger.log({ pullResult, rebaseResult }) 270 | } 271 | 272 | const checkoutLocalBranch = async (repoId: string, branchName: string) => { 273 | const repoSettings = await getRepoSettingsFromId(repoId) 274 | const gitRepo = git(okk(repoSettings.path)) 275 | try { 276 | await gitRepo.checkout(branchName) 277 | return true 278 | } catch (error) { 279 | return false 280 | } 281 | } 282 | 283 | const getRemote = async ( 284 | gitRepo: git.SimpleGit, 285 | ): Promise => { 286 | try { 287 | const remoteName = (await gitRepo.raw(['remote'])).split('\n')[0] 288 | 289 | const remoteUrl = ( 290 | await gitRepo.raw(['remote', 'get-url', `${okk(remoteName)}`]) 291 | ).split('\n')[0] 292 | 293 | const match = remoteUrl.match( 294 | /github.com[\/|:](?.*)\/(?.*)\.git$/, 295 | ) 296 | 297 | let orgID: string = '' 298 | let repoId: string = '' 299 | 300 | const groups = match && match.groups 301 | 302 | if (groups) { 303 | orgID = groups.orgID 304 | repoId = groups.repoId 305 | } 306 | 307 | okk(orgID) 308 | okk(repoId) 309 | 310 | return { remoteName, orgID, repoId } 311 | } catch (error) { 312 | logger.log(error) 313 | return false 314 | } 315 | } 316 | 317 | const getRepoFromPath = async (path: string) => { 318 | let result: git.SimpleGit | false = false 319 | 320 | try { 321 | result = git(okk(path)) 322 | } catch (error) { 323 | logger.error('getRepoFromPath', error) 324 | } 325 | 326 | return result 327 | } 328 | 329 | export { 330 | createBranch, 331 | pushBranch, 332 | createBranchFromTicketId, 333 | getBranches, 334 | deleteBranch, 335 | pullActiveBranch, 336 | rebaseLocalBranch, 337 | checkoutLocalBranch, 338 | getRemote, 339 | getRepoFromPath, 340 | } 341 | -------------------------------------------------------------------------------- /src/renderer/components/TicketRow.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | 3 | import React from 'react' 4 | import { css, jsx } from '@emotion/react' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | import { 7 | faTrashAlt, 8 | faHdd, 9 | faExchangeAlt, 10 | faCheckSquare, 11 | faCloud, 12 | faArrowUp, 13 | } from '@fortawesome/free-solid-svg-icons' 14 | import { faGithub } from '@fortawesome/free-brands-svg-icons' 15 | import { 16 | BadgeStyle, 17 | ClickableBadgeStyle, 18 | borderColor, 19 | ticketInProgressColor, 20 | ticketInactiveColor, 21 | titlesColor, 22 | activeCardAccentColor, 23 | cardsBGColor, 24 | actionsColor, 25 | ticketInactiveBGColor, 26 | ticketInProgressBGColor, 27 | } from './styles' 28 | import { shell, ipcRenderer } from 'electron' 29 | const { dialog } = require('electron').remote 30 | import { updatePR, generateNewOrCurrentPRLink } from '../plugins/github' 31 | import { IJiraTicket } from '../../shared/types/tickets' 32 | import { TBranches } from '../../shared/types/branches' 33 | import { ticketUrlFromKey } from '../../shared/plugins/jira' 34 | import { 35 | TExtendedPullRequest, 36 | TPushTaskOptions, 37 | CheckConclusion, 38 | } from '../../shared/types' 39 | import { 40 | IPC_CHECKOUT_LOCAL_BRANCH, 41 | IPC_CREATE_BRANCH, 42 | IPC_REBASE_BRANCH, 43 | IPC_DELETE_BRANCH, 44 | IPC_PUSH_BRANCH, 45 | } from '../../shared/constants' 46 | import { getRepoSettingsFromId } from '../../shared/helpers' 47 | 48 | interface ITicketRowProps { 49 | relatedPRs: Array 50 | relatedBranches: TBranches 51 | ticketData: IJiraTicket 52 | fetchData: () => void 53 | } 54 | 55 | const TicketRow: React.FC = ({ 56 | ticketData, 57 | relatedBranches, 58 | relatedPRs, 59 | fetchData, 60 | }) => { 61 | const isActiveTicket = ticketData.fields.status.name 62 | .toLowerCase() 63 | .includes('progress') 64 | 65 | return ( 66 |
76 |
77 | 80 | shell.openExternal(await ticketUrlFromKey(ticketData.key)) 81 | } 82 | css={css` 83 | ${BadgeStyle} 84 | ${ClickableBadgeStyle} 85 | background-color: ${ 86 | isActiveTicket ? ticketInProgressBGColor : ticketInactiveBGColor 87 | }; 88 | color: ${ 89 | isActiveTicket ? ticketInProgressColor : ticketInactiveColor 90 | }; 91 | `} 92 | > 93 | 98 | {ticketData.key} 99 | 100 | 101 | 102 | 109 | {ticketData.fields.summary} 110 | 111 | { 113 | await ipcRenderer.invoke(IPC_CREATE_BRANCH, ticketData.key) 114 | }} 115 | css={css` 116 | ${BadgeStyle} 117 | ${ClickableBadgeStyle} 118 | color: #fff; 119 | background-color: ${ticketInProgressBGColor}; 120 | `} 121 | > 122 | +branch 123 | 124 |
125 | 126 |
132 | {relatedBranches.map((relatedBranch) => ( 133 | 147 | 156 | 157 | { 162 | if (relatedBranch.isRemote) { 163 | shell.openExternal( 164 | generateNewOrCurrentPRLink({ 165 | repoId: relatedBranch.repoId, 166 | orgID: relatedBranch.orgID, 167 | branchName: relatedBranch.name, 168 | }), 169 | ) 170 | } else { 171 | const path = await getRepoSettingsFromId(relatedBranch.repoId) 172 | 173 | shell.openExternal(`vscode://file${path.path}`) 174 | } 175 | }} 176 | > 177 | {`${relatedBranch.repoId}:`} 182 | {`${relatedBranch.name}`} 183 | 184 | 185 | {!relatedBranch.isRemote && !relatedBranch.isCheckedout && ( 186 | { 189 | ipcRenderer.invoke( 190 | IPC_CHECKOUT_LOCAL_BRANCH, 191 | relatedBranch.repoId, 192 | relatedBranch.name, 193 | ) 194 | }} 195 | css={css` 196 | ${ClickableBadgeStyle} 197 | `} 198 | /> 199 | )} 200 | 201 | {!relatedBranch.isRemote && ( 202 | { 205 | ipcRenderer.invoke( 206 | IPC_REBASE_BRANCH, 207 | relatedBranch.repoId, 208 | relatedBranch.name, 209 | ) 210 | }} 211 | css={css` 212 | ${ClickableBadgeStyle} 213 | `} 214 | /> 215 | )} 216 | 217 | {!relatedBranch.isRemote && ( 218 | { 221 | const result = await dialog.showMessageBox({ 222 | buttons: ['normal', 'skip checks'], 223 | defaultId: 0, 224 | message: `Push [${relatedBranch.name}]?`, 225 | detail: `${relatedBranch.orgID}:${relatedBranch.repoId}`, 226 | }) 227 | 228 | const options: TPushTaskOptions = { 229 | repoId: relatedBranch.repoId, 230 | skipChecks: result.response === 1, 231 | branchName: relatedBranch.name, 232 | } 233 | 234 | await ipcRenderer.invoke(IPC_PUSH_BRANCH, options) 235 | }} 236 | css={css` 237 | ${ClickableBadgeStyle} 238 | `} 239 | /> 240 | )} 241 | 242 | { 245 | await ipcRenderer.invoke(IPC_DELETE_BRANCH, { 246 | repoId: relatedBranch.repoId, 247 | branchName: relatedBranch.name, 248 | isRemote: relatedBranch.isRemote, 249 | }) 250 | }} 251 | css={css` 252 | ${ClickableBadgeStyle} 253 | `} 254 | /> 255 | 256 | ))} 257 |
258 | 259 |
260 | {relatedPRs.map( 261 | ({ 262 | id, 263 | html_url, 264 | number, 265 | head, 266 | title, 267 | mergeable_state, 268 | checksStatus, 269 | }) => ( 270 |
271 | 291 | { 294 | switch (mergeable_state) { 295 | case 'behind': 296 | await updatePR(head.repo.name, number) 297 | fetchData() 298 | break 299 | 300 | default: 301 | break 302 | } 303 | }} 304 | css={css` 305 | margin: 0 5px; 306 | `} 307 | /> 308 | shell.openExternal(html_url)} 310 | css={css` 311 | cursor: pointer; 312 | `} 313 | > 314 | {`${head.repo.name} #${number}`} 320 | {title} 321 | 322 | 323 |
324 | ), 325 | )} 326 |
327 |
328 | ) 329 | } 330 | 331 | export { TicketRow } 332 | -------------------------------------------------------------------------------- /src/renderer/modals/Settings.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | 3 | import React, { useState } from 'react' 4 | import { connect, ConnectedProps } from 'react-redux' 5 | import { 6 | saveSettings, 7 | deleteSettings, 8 | } from '../../shared/store/settings/actions' 9 | import { Link } from 'react-router-dom' 10 | import { Form, Field } from 'react-final-form' 11 | import arrayMutators from 'final-form-arrays' 12 | import { FieldArray } from 'react-final-form-arrays' 13 | import { TAppState } from '../../main/store' 14 | import { jsx, css } from '@emotion/react' 15 | import { shell, ipcRenderer } from 'electron' 16 | import { folderPicker } from '../plugins/dialogs' 17 | 18 | import { 19 | TextFieldWrapper, 20 | Error, 21 | Label, 22 | SupportLink, 23 | textColor, 24 | } from '../components/styles' 25 | import { IPC_GET_GIT_REMOTE } from '../../shared/constants' 26 | import { TRepoRemote } from '../../shared/types' 27 | import { getProfileSettings } from '../../shared/helpers' 28 | import { ISettingsProfile } from '../../shared/types/settings' 29 | 30 | const mapState = (state: TAppState) => ({ 31 | settings: state.settings, 32 | }) 33 | 34 | const mapDispatch = { 35 | saveSettingsAction: saveSettings, 36 | deleteSettingsAction: deleteSettings, 37 | } 38 | 39 | const connector = connect(mapState, mapDispatch) 40 | 41 | type TProps = ConnectedProps 42 | 43 | const settings: React.FC = ({ 44 | settings, 45 | saveSettingsAction, 46 | deleteSettingsAction, 47 | }) => { 48 | const initialProfileIds = settings.profiles.map((profile) => profile.id) 49 | 50 | const [newProfile, setNewProfile] = useState('') 51 | const [profileIds, setProfileIds] = useState(initialProfileIds) 52 | const [activeProfile, setActiveProfile] = useState( 53 | settings.activeProfile, 54 | ) 55 | 56 | const activeSettings = getProfileSettings(settings, activeProfile) 57 | 58 | return ( 59 |
65 | 73 | x 74 | 75 |
{ 78 | saveSettingsAction(settings, profileValues, activeProfile) 79 | }} 80 | mutators={{ 81 | ...arrayMutators, 82 | }} 83 | validate={(values) => { 84 | const errors: any = {} 85 | 86 | if (!values.githubAuth) { 87 | errors.githubAuth = 'Required' 88 | } 89 | if (!values.githubUserName) { 90 | errors.githubUserName = 'Required' 91 | } 92 | if (!values.jiraHost) { 93 | errors.jiraHost = 'Required' 94 | } 95 | if (!values.jiraJQL) { 96 | errors.jiraJQL = 'Required' 97 | } 98 | if (!values.jiraEmail) { 99 | errors.jiraEmail = 'Required' 100 | } 101 | if (!values.jiraAuth) { 102 | errors.jiraAuth = 'Required' 103 | } 104 | 105 | if (values.reposList && values.reposList.length > 0) { 106 | for (let i = 0; i < values.reposList.length; i++) { 107 | const repo = values.reposList[i] 108 | 109 | if (!repo.path) { 110 | errors[`reposList[${i}].path`] = 'Required' 111 | } 112 | if (!repo.repoId) { 113 | errors[`reposList[${i}].repoId`] = 'Required' 114 | } 115 | if (!repo.remoteName) { 116 | errors[`reposList[${i}].remoteName`] = 'Required' 117 | } 118 | if (!repo.orgID) { 119 | errors[`reposList[${i}].orgID`] = 'Required' 120 | } 121 | } 122 | } 123 | return errors 124 | }} 125 | render={({ handleSubmit, invalid, form }) => ( 126 | 133 |

139 | Settings: 140 |

141 |
149 |

155 | Settings Profile: 156 |

157 | 173 | 174 | {profileIds.length > 1 && ( 175 | 196 | )} 197 | 198 |
199 | setNewProfile(e.target.value)} 202 | value={newProfile} 203 | /> 204 | 217 |
218 |
219 | 220 | 221 | {({ input, meta }) => ( 222 | 223 | 224 | 232 | 233 | {meta.error && meta.touched && {meta.error}} 234 | 235 | )} 236 | 237 | 238 | {({ input, meta }) => ( 239 | 240 | 250 | 251 | 259 | 260 | {meta.error && meta.touched && {meta.error}} 261 | 262 | )} 263 | 264 | 265 | {({ input, meta }) => ( 266 | 267 | 268 | 276 | 277 | {meta.error && meta.touched && {meta.error}} 278 | 279 | )} 280 | 281 | 282 | {({ input, meta }) => ( 283 | 284 | 285 | 293 | 294 | {meta.error && meta.touched && {meta.error}} 295 | 296 | )} 297 | 298 | 299 | {({ input, meta }) => ( 300 | 301 | 313 | 320 | 321 | {meta.error && meta.touched && {meta.error}} 322 | 323 | )} 324 | 325 | 326 | {({ input, meta }) => ( 327 | 328 | 340 | 347 | 348 | {meta.error && meta.touched && {meta.error}} 349 | 350 | )} 351 | 352 | 353 | 354 | {({ input }) => { 355 | return ( 356 |
357 | 365 | 373 |
374 | ) 375 | }} 376 |
377 | 378 |

384 | repos: 385 | 419 |

420 | 421 | {({ fields }) => 422 | fields.map((fieldKey, index) => ( 423 |
432 | fields.remove(index)} 434 | css={css` 435 | cursor: pointer; 436 | position: absolute; 437 | right: 0; 438 | top: 0; 439 | `} 440 | > 441 | ❌ 442 | 443 | 444 | {({ input, meta }) => ( 445 | 446 | 447 | 448 | 449 | {meta.error && meta.touched && ( 450 | {meta.error} 451 | )} 452 | 453 | )} 454 | 455 | 456 | 457 | {({ input, meta }) => ( 458 | 463 | 464 | 474 | 475 | {meta.error && meta.touched && ( 476 | {meta.error} 477 | )} 478 | 479 | )} 480 | 481 | 482 | 483 | {({ input, meta }) => ( 484 | 485 | 486 | 496 | 497 | {meta.error && meta.touched && ( 498 | {meta.error} 499 | )} 500 | 501 | )} 502 | 503 | 504 | 505 | {({ input, meta }) => ( 506 | 507 | 508 | 518 | 519 | {meta.error && meta.touched && ( 520 | {meta.error} 521 | )} 522 | 523 | )} 524 | 525 | 526 | 527 | {({ input }) => { 528 | return ( 529 |
530 | 538 | 545 |
546 | ) 547 | }} 548 |
549 |
550 | )) 551 | } 552 |
553 | 563 |
564 | )} 565 | /> 566 |
567 | ) 568 | } 569 | 570 | const Settings = connector(settings) 571 | 572 | export { Settings } 573 | --------------------------------------------------------------------------------