@pando/git-remote-pando
Version:
git-remote-helper for pando
514 lines (447 loc) • 15.7 kB
text/typescript
import debug from 'debug'
import ETHProvider from 'eth-provider'
import fs from 'fs-extra'
import json from 'jsonfile'
import Level from 'level'
import ora from 'ora'
import os from 'os'
import path from 'path'
import shell from 'shelljs'
import contractor from 'truffle-contract'
import Web3 from 'web3'
import GitHelper from './lib/git'
import IPLDHelper from './lib/ipld'
import LineHelper from './lib/line'
const _timeout = async (duration: any): Promise<void> => {
return new Promise<any>((resolve, reject) => {
setTimeout(() => {
resolve()
}, duration)
})
}
export default class Helper {
// name and url
public name: string
public url: string
// address and path shortcuts
public address: string
public path: string
// config
public config: any
// lib
public debug: any
public line: LineHelper
public git: GitHelper
public ipld: IPLDHelper
// db
private _db: any
// provider and web3
private _provider: any
private _web3: any
// artifacts and contracts
private _repo!: any
private _kernel!: any
constructor(name: string = '_', url: string) {
// name and url
this.name = name
this.url = url
// address and path shortcuts
this.address = this.url.split('://')[1]
this.path = path.resolve(process.env.GIT_DIR as string)
// config
this.config = this._config()
// lib
this.debug = debug('pando')
this.line = new LineHelper()
this.git = new GitHelper(this)
this.ipld = new IPLDHelper(this)
}
// OK
public async initialize(): Promise<void> {
// create dirs
fs.ensureDirSync(path.join(this.path, 'refs', 'remotes', this.name))
fs.ensureDirSync(path.join(this.path, 'pando', 'refs'))
// load db
this._db = Level(path.join(this.path, 'pando', 'refs', this.address))
// initialize web3
this._provider = await this._ethConnect()
this._web3 = new Web3(this._provider)
// initialize repo contract
this._repo = { artifact: undefined, contract: undefined }
// tslint:disable-next-line:no-submodule-imports
this._repo.artifact = contractor(require('@pando/repository/abi/PandoRepository.json'))
this._repo.artifact.setProvider(this._provider)
this._repo.artifact.defaults({ from: this.config.ethereum.account })
this._repo.artifact.autoGas = true
this._repo.artifact.gasMultiplier = 3
this._repo.contract = await this._repo.artifact.at(this.address)
// initialize kernel contract
this._kernel = { artifact: undefined, contract: undefined }
// tslint:disable-next-line:no-submodule-imports
this._kernel.artifact = contractor(require('@pando/repository/abi/Kernel.json'))
this._kernel.artifact.setProvider(this._provider)
this._kernel.artifact.defaults({ from: this.config.ethereum.account })
this._kernel.artifact.autoGas = true
this._kernel.artifact.gasMultiplier = 3
this._kernel.contract = await this._kernel.artifact.at(await this._repo.contract.kernel())
}
// OK
public async run(): Promise<void> {
while (true) {
const cmd = await this.line.next()
switch (this._cmd(cmd)) {
case 'capabilities':
await this._handleCapabilities()
break
case 'list for-push':
await this._handleList({ forPush: true })
break
case 'list':
await this._handleList()
break
case 'fetch':
await this._handleFetch(cmd)
break
case 'push':
await this._handlePush(cmd)
break
case 'end':
this._exit()
case 'unknown':
throw new Error('Unknown command: ' + cmd)
}
}
}
/***** commands handling methods *****/
// OK
private async _handleCapabilities(): Promise<void> {
this.debug('cmd', 'capabilities')
// this._send('option')
this._send('fetch')
this._send('push')
this._send('')
}
// OK
private async _handleList({ forPush = false }: { forPush?: boolean } = {}): Promise<void> {
forPush ? this.debug('cmd', 'list', 'for-push') : this.debug('cmd, list')
const refs = await this._fetchRefs()
// tslint:disable-next-line:forin
for (const ref in refs) {
this._send(this.ipld.cidToSha(refs[ref]) + ' ' + ref)
}
// force HEAD to master and update once pando handle symbolic refs
this._send('@refs/heads/master' + ' ' + 'HEAD')
this._send('')
}
// OK
private async _handleFetch(line: string): Promise<void> {
this.debug('cmd', line)
while (true) {
const [cmd, oid, name] = line.split(' ')
await this._fetch(oid, name)
line = await this.line.next()
if (line === '') break
}
this._send('')
}
// OK
private async _handlePush(line: string): Promise<void> {
this.debug('cmd', line)
while (true) {
const [src, dst] = line.split(' ')[1].split(':')
if (src === '') {
await this._delete(dst)
} else {
await this._push(src, dst)
}
line = await this.line.next()
if (line === '') break
}
this._send('')
}
/***** refs db methods *****/
// OK
private async _fetchRefs(): Promise<object> {
this.debug('fetching remote refs from chain')
let start
let block
let events
const ops: object[] = []
const updates: object = {}
const spinner = ora('Fetching remote refs from chain [this may take a while]').start()
try {
start = await this._db.get('@block')
} catch (err) {
if (err.message === 'Key not found in database [@block]') {
start = 0
} else {
throw err
}
}
try {
block = await this._web3.eth.getBlockNumber()
events = await this._repo.contract.getPastEvents('UpdateRef', {
fromBlock: start,
toBlock: 'latest',
})
for (const event of events) {
updates[event.args.ref] = event.args.hash
}
// tslint:disable-next-line:forin
for (const ref in updates) {
ops.push({ type: 'put', key: ref, value: updates[ref] })
}
ops.push({ type: 'put', key: '@block', value: block })
await this._db.batch(ops)
spinner.succeed('Remote refs fetched from chain')
return this._getRefs()
} catch (err) {
spinner.fail('Failed to fetch remote refs from chain')
throw err
}
}
// OK
private async _getRefs(): Promise<object> {
this.debug('reading refs from local db')
const refs: any = {}
return new Promise<object>((resolve, reject) => {
this._db
.createReadStream()
.on('data', ref => {
if (ref.key !== '@block') refs[ref.key] = ref.value
})
.on('error', err => {
reject(err)
})
.on('end', () => {
//
})
.on('close', () => {
resolve(refs)
})
})
}
/***** core methods *****/
// OK
private async _fetch(oid: string, ref: string): Promise<void> {
this.debug('fetching', ref, oid, this.ipld.shaToCid(oid))
await this.git.download(oid)
}
// OK
private async _push(src: string, dst: string): Promise<void> {
this.debug('pushing', src, 'to', dst)
return new Promise<void>(async (resolve, reject) => {
let spinner
let head
let txHash
let mapping: any = {}
const puts: any = []
const pins: any = []
try {
const refs = await this._getRefs()
const remote: any = refs[dst]
const srcBranch = src.split('/').pop()
const dstBranch = dst.split('/').pop()
const revListCmd = remote ? `git rev-list --left-only ${srcBranch}...${this.name}/${dstBranch}` : 'git rev-list --all'
const commits = shell
.exec(revListCmd, { silent: true })
.stdout.split('\n')
.slice(0, -1)
// checking permissions
try {
spinner = ora(`Checking permissions over ${this.address}`).start()
if (process.env.PANDO_OPEN_PR) {
if (await this._kernel.contract.hasPermission(this.config.ethereum.account, this.address, await this._repo.contract.OPEN_PR_ROLE(), '0x0')) {
spinner.succeed(`You have open PR permission over ${this.address}`)
} else {
spinner.fail(`You do not have open PR permission over ${this.address}`)
this._die()
}
} else {
if (await this._kernel.contract.hasPermission(this.config.ethereum.account, this.address, await this._repo.contract.PUSH_ROLE(), '0x0')) {
spinner.succeed(`You have push permission over ${this.address}`)
} else {
spinner.fail(`You do not have push permission over ${this.address}. Try to run 'git pando pr open' to open a push request.`)
this._die()
}
}
} catch (err) {
spinner.fail(`Failed to check permissions over ${this.address}: ` + err.message)
this._die()
}
// collect git objects
try {
spinner = ora('Collecting git objects [this may take a while]').start()
for (const commit of commits) {
const _mapping = await this.git.collect(commit, mapping)
mapping = { ...mapping, ..._mapping }
}
head = this.ipld.shaToCid(commits[0])
// tslint:disable-next-line:forin
for (const entry in mapping) {
puts.push(this.ipld.put(mapping[entry]))
}
spinner.succeed('Git objects collected')
} catch (err) {
spinner.fail('Failed to collect git objects: ' + err.message)
this._die()
}
// upload git objects
try {
spinner = ora('Uploading git objects to IPFS').start()
await Promise.all(puts)
spinner.succeed('Git objects uploaded to IPFS')
} catch (err) {
spinner.fail('Failed to upload git objects to IPFS: ' + err.message)
this._die()
}
// pin git objects
try {
// tslint:disable-next-line:forin
for (const entry in mapping) {
pins.push(this.ipld.pin(entry))
}
spinner = ora('Pinning git objects to IPFS').start()
await Promise.all(pins)
spinner.succeed('Git objects pinned to IPFS')
} catch (err) {
spinner.fail('Failed to pin git objects to IPFS: ' + err.message)
this._die()
}
// register on chain
if (!process.env.PANDO_OPEN_PR) {
try {
spinner = ora(`Registering ref ${dst} ${head} on-chain`).start()
this._repo.contract
.push(dst, head)
.on('error', err => {
if (!err.message.includes('Failed to subscribe to new newBlockHeaders to confirm the transaction receipts')) {
spinner.fail(`Failed to register ref ${dst} ${head} on-chain: ` + err.message)
this._die()
}
})
.on('transactionHash', hash => {
txHash = hash
spinner.text = `Registering ref ${dst} ${head} on-chain through tx ${txHash}`
})
.then(receipt => {
spinner.succeed(`Ref ${dst} ${head} registered on-chain through tx ${txHash}`)
this._send(`ok ${dst}`)
resolve()
})
.catch(err => {
spinner.fail(`Failed to register ref ${dst} ${head} on-chain: ` + err.message)
this._die()
})
} catch (err) {
spinner.fail(`Failed to register ref ${dst} ${head} on-chain: ` + err.message)
this._die()
}
} else {
try {
spinner = ora(`Opening PR for ${dst} ${head} on-chain`).start()
this._repo.contract
.openPR(process.env.PANDO_PR_TITLE, process.env.PANDO_PR_DESCRIPTION || '', dst, head)
.on('error', err => {
if (!err.message.includes('Failed to subscribe to new newBlockHeaders to confirm the transaction receipts')) {
spinner.fail(`Failed to open PR for ${dst} ${head} on-chain: ` + err.message)
this._die()
}
})
.on('transactionHash', hash => {
txHash = hash
spinner.text = `Opening PR for ${dst} ${head} on-chain through tx ${txHash}`
})
.then(receipt => {
const id = receipt.logs.filter(l => l.event === 'OpenPR')[0].args.id
spinner.succeed(`PR #${id} for ${dst} ${head} opened on-chain through tx ${txHash}`)
this._send(`error ${dst} Relax! This is because you do not have push permission: PR #${id} has been opened for you instead`)
resolve()
})
.catch(err => {
spinner.fail(`Failed to open PR for ${dst} ${head} on-chain: ` + err.message)
this._die()
})
} catch (err) {
spinner.fail(`Failed to open PR for ${dst} ${head} on-chain: ` + err.message)
this._die()
}
}
} catch (err) {
this._die(err.message)
}
})
}
// TODO
private async _delete(dst: string): Promise<void> {
this.debug('deleting', dst)
}
/***** utility methods *****/
// OK
private async _ethConnect(): Promise<any> {
this.debug('connecting to gateway', this.config.ethereum.gateway)
const provider = ETHProvider(this.config.ethereum.gateway)
const spinner = ora('Connecting to Ethereum').start()
while (true) {
try {
const accounts = await provider.send('eth_accounts')
spinner.stop()
for (const account of accounts) {
if (account === this.config.ethereum.account) return provider
}
this._die("Failed to access your Ethereum account. Update your gateway configuration or run 'git pando config' to select another account.")
} catch (err) {
if (err.code === 4100 || err.code === 4001) {
spinner.text = `Error connecting to Ethereum. ${err.message}.`
await _timeout(2000)
} else {
spinner.stop()
this._die('Failed to connect to Ethereum. Make sure your Ethereum gateway is running.')
}
}
}
}
// OK
private _config(): any {
const LOCAL = path.join(this.path, 'pando', '.pandorc')
const GLOBAL = path.join(os.homedir(), '.pandorc')
if (fs.pathExistsSync(LOCAL)) return json.readFileSync(LOCAL)
if (fs.pathExistsSync(GLOBAL)) return json.readFileSync(GLOBAL)
this._die("No configuration file found. Run 'git-pando config' and come back to us.")
}
// OK
private _cmd(line: string): string {
if (line === 'capabilities') {
return 'capabilities'
} else if (line === 'list for-push') {
return 'list for-push'
} else if (line === 'list') {
return 'list'
} else if (line.startsWith('option')) {
return 'option'
} else if (line.startsWith('fetch')) {
return 'fetch'
} else if (line.startsWith('push')) {
return 'push'
} else if (line === '') {
return 'end'
} else {
return 'unknown'
}
}
// OK
private _send(message): void {
// tslint:disable-next-line:no-console
console.log(message)
}
// OK
private _die(message?: string): void {
// tslint:disable-next-line:no-console
if (message) console.error(message)
process.exit(1)
}
// OK
private _exit(): void {
process.exit(0)
}
}