Merge remote-tracking branch 'origin/master' into pr-create

This commit is contained in:
Mislav Marohnić 2019-11-11 12:24:09 +01:00
commit a275398dac
39 changed files with 13268 additions and 226 deletions

View file

@ -0,0 +1 @@
node_modules

View file

@ -0,0 +1,9 @@
name: 'Copy release to another repo'
description: 'Copy a release from one repo to another'
author: 'probablycorey'
runs:
using: 'node12'
main: './lib/index.js'
outputs:
asset-url:
description: The url of the asset that was copied

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,362 @@
{
"requires": true,
"lockfileVersion": 1,
"dependencies": {
"@actions/core": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.0.tgz",
"integrity": "sha512-ZKdyhlSlyz38S6YFfPnyNgCDZuAF2T0Qv5eHflNWytPS8Qjvz39bZFMry9Bb/dpSnqWcNeav5yM2CTYpJeY+Dw=="
},
"@actions/github": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@actions/github/-/github-1.1.0.tgz",
"integrity": "sha512-cHf6PyoNMdei13jEdGPhKprIMFmjVVW/dnM5/9QmQDJ1ZTaGVyezUSCUIC/ySNLRvDUpeFwPYMdThSEJldSbUw==",
"requires": {
"@octokit/graphql": "^2.0.1",
"@octokit/rest": "^16.15.0"
}
},
"@octokit/endpoint": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-5.5.1.tgz",
"integrity": "sha512-nBFhRUb5YzVTCX/iAK1MgQ4uWo89Gu0TH00qQHoYRCsE12dWcG1OiLd7v2EIo2+tpUKPMOQ62QFy9hy9Vg2ULg==",
"requires": {
"@octokit/types": "^2.0.0",
"is-plain-object": "^3.0.0",
"universal-user-agent": "^4.0.0"
},
"dependencies": {
"universal-user-agent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.0.tgz",
"integrity": "sha512-eM8knLpev67iBDizr/YtqkJsF3GK8gzDc6st/WKzrTuPtcsOKW/0IdL4cnMBsU69pOx0otavLWBDGTwg+dB0aA==",
"requires": {
"os-name": "^3.1.0"
}
}
}
},
"@octokit/graphql": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-2.1.3.tgz",
"integrity": "sha512-XoXJqL2ondwdnMIW3wtqJWEwcBfKk37jO/rYkoxNPEVeLBDGsGO1TCWggrAlq3keGt/O+C/7VepXnukUxwt5vA==",
"requires": {
"@octokit/request": "^5.0.0",
"universal-user-agent": "^2.0.3"
}
},
"@octokit/request": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.3.1.tgz",
"integrity": "sha512-5/X0AL1ZgoU32fAepTfEoggFinO3rxsMLtzhlUX+RctLrusn/CApJuGFCd0v7GMFhF+8UiCsTTfsu7Fh1HnEJg==",
"requires": {
"@octokit/endpoint": "^5.5.0",
"@octokit/request-error": "^1.0.1",
"@octokit/types": "^2.0.0",
"deprecation": "^2.0.0",
"is-plain-object": "^3.0.0",
"node-fetch": "^2.3.0",
"once": "^1.4.0",
"universal-user-agent": "^4.0.0"
},
"dependencies": {
"universal-user-agent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.0.tgz",
"integrity": "sha512-eM8knLpev67iBDizr/YtqkJsF3GK8gzDc6st/WKzrTuPtcsOKW/0IdL4cnMBsU69pOx0otavLWBDGTwg+dB0aA==",
"requires": {
"os-name": "^3.1.0"
}
}
}
},
"@octokit/request-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.1.0.tgz",
"integrity": "sha512-06lt8PulL3rKpmwzYLCeLEt1iHFoj8l0PLkObAtp5Cx0Wwd1+5FAa9u6UXjA0kzYsfbjBKF9TtO9CuXelKiYlw==",
"requires": {
"@octokit/types": "^2.0.0",
"deprecation": "^2.0.0",
"once": "^1.4.0"
}
},
"@octokit/rest": {
"version": "16.34.1",
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-16.34.1.tgz",
"integrity": "sha512-JUoS12cdktf1fv86rgrjC/RvYLuL+o7p57W7zX1x7ANFJ7OvdV8emvUNkFlcidEaOkYrxK3SoWgQFt3FhNmabA==",
"requires": {
"@octokit/request": "^5.2.0",
"@octokit/request-error": "^1.0.2",
"atob-lite": "^2.0.0",
"before-after-hook": "^2.0.0",
"btoa-lite": "^1.0.0",
"deprecation": "^2.0.0",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"lodash.uniq": "^4.5.0",
"octokit-pagination-methods": "^1.1.0",
"once": "^1.4.0",
"universal-user-agent": "^4.0.0"
},
"dependencies": {
"universal-user-agent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.0.tgz",
"integrity": "sha512-eM8knLpev67iBDizr/YtqkJsF3GK8gzDc6st/WKzrTuPtcsOKW/0IdL4cnMBsU69pOx0otavLWBDGTwg+dB0aA==",
"requires": {
"os-name": "^3.1.0"
}
}
}
},
"@octokit/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.0.0.tgz",
"integrity": "sha512-467rp1g6YuxuNbu1m3A5BuGWxtzyVE8sAyN9+k3kb2LdnpmLPTiPsywbYmcckgfGZ+/AGpAaNrVx7131iSUXbQ==",
"requires": {
"@types/node": "^12.11.1"
}
},
"@types/node": {
"version": "12.12.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.5.tgz",
"integrity": "sha512-KEjODidV4XYUlJBF3XdjSH5FWoMCtO0utnhtdLf1AgeuZLOrRbvmU/gaRCVg7ZaQDjVf3l84egiY0mRNe5xE4A=="
},
"@types/node-fetch": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.3.tgz",
"integrity": "sha512-X3TNlzZ7SuSwZsMkb5fV7GrPbVKvHc2iwHmslb8bIxRKWg2iqkfm3F/Wd79RhDpOXR7wCtKAwc5Y2JE6n/ibyw==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@zeit/ncc": {
"version": "0.20.5",
"resolved": "https://registry.npmjs.org/@zeit/ncc/-/ncc-0.20.5.tgz",
"integrity": "sha512-XU6uzwvv95DqxciQx+aOLhbyBx/13ky+RK1y88Age9Du3BlA4mMPCy13BGjayOrrumOzlq1XV3SD/BWiZENXlw==",
"dev": true
},
"atob-lite": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz",
"integrity": "sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY="
},
"before-after-hook": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.1.0.tgz",
"integrity": "sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A=="
},
"btoa-lite": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/btoa-lite/-/btoa-lite-1.0.0.tgz",
"integrity": "sha1-M3dm2hWAEhD92VbCLpxokaudAzc="
},
"cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
"integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
"requires": {
"nice-try": "^1.0.4",
"path-key": "^2.0.1",
"semver": "^5.5.0",
"shebang-command": "^1.2.0",
"which": "^1.2.9"
}
},
"deprecation": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz",
"integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="
},
"end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
"requires": {
"once": "^1.4.0"
}
},
"execa": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
"integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
"requires": {
"cross-spawn": "^6.0.0",
"get-stream": "^4.0.0",
"is-stream": "^1.1.0",
"npm-run-path": "^2.0.0",
"p-finally": "^1.0.0",
"signal-exit": "^3.0.0",
"strip-eof": "^1.0.0"
}
},
"get-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
"integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
"requires": {
"pump": "^3.0.0"
}
},
"is-plain-object": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz",
"integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==",
"requires": {
"isobject": "^4.0.0"
}
},
"is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
"integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
},
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
},
"isobject": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz",
"integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA=="
},
"lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
},
"lodash.set": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
"integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM="
},
"lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
"integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M="
},
"macos-release": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz",
"integrity": "sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA=="
},
"nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="
},
"node-fetch": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
},
"npm-run-path": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
"integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=",
"requires": {
"path-key": "^2.0.0"
}
},
"octokit-pagination-methods": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz",
"integrity": "sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ=="
},
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"requires": {
"wrappy": "1"
}
},
"os-name": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/os-name/-/os-name-3.1.0.tgz",
"integrity": "sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==",
"requires": {
"macos-release": "^2.2.0",
"windows-release": "^3.1.0"
}
},
"p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
},
"path-key": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
"integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A="
},
"pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
"integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
"requires": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
},
"shebang-command": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
"integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
"requires": {
"shebang-regex": "^1.0.0"
}
},
"shebang-regex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
"integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM="
},
"signal-exit": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0="
},
"strip-eof": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
"integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8="
},
"universal-user-agent": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-2.1.0.tgz",
"integrity": "sha512-8itiX7G05Tu3mGDTdNY2fB4KJ8MgZLS54RdG6PkkfwMAavrXu1mV/lls/GABx9O3Rw4PnTtasxrvbMQoBYY92Q==",
"requires": {
"os-name": "^3.0.0"
}
},
"which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"requires": {
"isexe": "^2.0.0"
}
},
"windows-release": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/windows-release/-/windows-release-3.2.0.tgz",
"integrity": "sha512-QTlz2hKLrdqukrsapKsINzqMgOUpQW268eJ0OaOpJN32h272waxR9fkB9VoWRtK7uKHG5EHJcTXQBD8XZVJkFA==",
"requires": {
"execa": "^1.0.0"
}
},
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
}
}
}

View file

@ -0,0 +1,20 @@
{
"license": "ISC",
"scripts": {
"build": "rm -rf lib && ncc build -s src/index.ts -o lib"
},
"dependencies": {
"@actions/core": "^1.2.0",
"@actions/github": "^1.1.0",
"node-fetch": "^2.6.0"
},
"devDependencies": {
"@types/node-fetch": "^2.5.3",
"@zeit/ncc": "^0.20.5"
},
"prettier": {
"trailingComma": "es5",
"semi": false,
"singleQuote": true
}
}

View file

@ -0,0 +1,140 @@
import {getInput, setOutput, setFailed} from '@actions/core'
import {context, GitHub} from '@actions/github'
import * as fs from 'fs'
import * as path from 'path'
import fetch from 'node-fetch'
import { ReposCreateReleaseResponse, ReposGetReleaseByTagResponse } from '@octokit/rest'
const GITHUB_TOKEN = getEnvVar('GITHUB_TOKEN')
const UPLOAD_GITHUB_TOKEN = getEnvVar('UPLOAD_GITHUB_TOKEN')
process.on('unhandledRejection', (reason: any, _: Promise<any>) =>
handleError(reason)
)
main().catch(handleError)
async function main() {
// Get the release from the current repository
const tag = context.ref.replace('refs/tags/', '')
const release = await getRelease(context.repo.owner, context.repo.repo, tag)
// Create a new release in another repository
const [targetOwner, targetRepo] = getInput('target-repo').split('/')
let publicRelease: ReposCreateReleaseResponse | ReposCreateReleaseResponse
try {
publicRelease = await getRelease(targetOwner, targetRepo, tag)
} catch (error) {
if (error.status && error.status == 404) {
publicRelease = await createRelease(targetOwner, targetRepo, release)
} else {
throw error
}
}
for (const asset of release.assets) {
if (!(asset.name.match(/macOS/) || asset.name.match(/darwin/)) && !asset.name.match(/linux/)) {
continue
}
const filePath = await downloadAsset(asset)
for (const existingAsset of publicRelease.assets) {
if (existingAsset.name == asset.name) {
await deleteAsset(targetOwner, targetRepo, existingAsset)
}
}
const assetUrl = await uploadAsset(publicRelease, filePath)
if (asset.name.match(/macOS/) || asset.name.match(/darwin/)) {
setOutput('asset-url', assetUrl)
}
}
}
async function getRelease(owner: string, repo: string, tag: string) {
const octokit = new GitHub(GITHUB_TOKEN)
const response = await octokit.repos.getReleaseByTag({ owner, repo, tag })
return response.data
}
async function createRelease(owner: string, repo: string, release: ReposGetReleaseByTagResponse) {
const octokit = new GitHub(UPLOAD_GITHUB_TOKEN)
const response = await octokit.repos.createRelease({
owner,
repo,
tag_name: release.tag_name,
name: release.name,
body: '',
prerelease: release.prerelease,
draft: false,
})
return response.data
}
async function uploadAsset(release: ReposGetReleaseByTagResponse | ReposCreateReleaseResponse, filePath: string) {
const octokit = new GitHub(UPLOAD_GITHUB_TOKEN)
const response = await octokit.repos.uploadReleaseAsset({
url: release.upload_url,
file: fs.readFileSync(filePath),
name: path.basename(filePath),
headers: {
'content-type': 'application/octet-stream',
'content-length': fs.statSync(filePath).size,
},
})
// this seems like a bug in Rest.js types
const url: string = (<any>response.data).browser_download_url
return url
}
interface Asset {
id: number;
url: string;
name: string;
}
async function deleteAsset(owner: string, repo: string, asset: Asset) {
const octokit = new GitHub(UPLOAD_GITHUB_TOKEN)
await octokit.repos.deleteReleaseAsset({
owner,
repo,
asset_id: asset.id
})
}
async function downloadAsset(asset: Asset) {
let response = await fetch(asset.url, {
redirect: 'manual',
headers: {
Authorization: `token ${GITHUB_TOKEN}`,
Accept: 'application/octet-stream',
},
})
// Why didn't I just let fetch handle the redirect? Because that will
// will forwarded the Authorization header to S3 and AWS doesn't like that.
// For more details check out https://github.com/octokit/rest.js/issues/967
if (response.status === 302) {
response = await fetch(response.headers.get('location') || '', {
headers: { Accept: 'application/octet-stream' },
})
}
if (response.status === 200) {
const data = await response.buffer()
fs.writeFileSync(asset.name, data)
} else {
throw new Error('failed to download asset: ' + (await response.text()))
}
return asset.name
}
async function handleError(err: Error) {
console.error(err)
setFailed(err.message)
}
function getEnvVar(name: string): string {
const envVar = process.env[name]
if (!envVar) {
throw new Error(`env var named "${name} is not set"`)
}
return envVar
}

View file

@ -0,0 +1,11 @@
set -e
TOKEN="$(awk '/oauth_token/ {print $2}' ~/.config/hub | head -1)"
env \
"GITHUB_REPOSITORY=github/gh-cli" \
"GITHUB_REF=refs/tags/v0.0.195" \
"INPUT_TARGET-REPO=github/homebrew-gh" \
"GITHUB_TOKEN=$TOKEN" \
"UPLOAD_GITHUB_TOKEN=$TOKEN" \
node lib/index.js

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"outDir": "./lib",
"rootDir": "./src",
"module": "commonjs",
"target": "esnext",
"lib": ["es2017"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitAny": true,
"removeComments": false,
"preserveConstEnums": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

43
.github/workflows/releases.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: goreleaser
on:
push:
tags:
- "v*"
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Set up Go 1.13
id: go
uses: actions/setup-go@v1
with:
go-version: 1.13
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v1
with:
version: latest
args: release
env:
GH_OAUTH_CLIENT_ID: 178c6fc778ccc68e1d6a
GH_OAUTH_CLIENT_SECRET: ${{secrets.OAUTH_CLIENT_SECRET}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Copy release
id: copy
uses: ./.github/actions/copy-release-to-another-repo
with:
target-repo: github/homebrew-gh
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
UPLOAD_GITHUB_TOKEN: ${{secrets.UPLOAD_GITHUB_TOKEN}}
- name: Bump brew formula
uses: mislav/bump-homebrew-formula-action@v1.4
with:
formula-name: gh
homebrew-tap: github/homebrew-gh
download-url: ${{ steps.copy.outputs.asset-url }}
env:
COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }}

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
bin/gh
/gh-cli
.envrc
/dist

32
.goreleaser.yml Normal file
View file

@ -0,0 +1,32 @@
# Make sure to check the documentation at http://goreleaser.com
project_name: gh
before:
hooks:
- go mod tidy
builds:
- binary: bin/gh
ldflags:
- -s -w -X github.com/github/gh-cli/command.Version={{.Version}} -X github.com/github/gh-cli/command.BuildDate={{.Date}}
- -X github.com/github/gh-cli/context.oauthClientID={{.Env.GH_OAUTH_CLIENT_ID}}
- -X github.com/github/gh-cli/context.oauthClientSecret={{.Env.GH_OAUTH_CLIENT_SECRET}}
goos:
- linux
- darwin
- windows
goarch:
- amd64
archives:
- name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
wrap_in_directory: true
replacements:
darwin: macOS
format: tar.gz
format_overrides:
- goos: windows
format: zip
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"

View file

@ -1,10 +1,16 @@
BUILD_FILES = $(shell go list -f '{{range .GoFiles}}{{$$.Dir}}/{{.}}\
{{end}}' ./...)
# export GOFLAGS := -mod=vendor $GOFLAGS
GH_VERSION = $(shell go describe --tags 2>/dev/null || git rev-parse --short HEAD)
LDFLAGS := -X github.com/github/gh-cli/command.Version=$(GH_VERSION) $(LDFLAGS)
LDFLAGS := -X github.com/github/gh-cli/command.BuildDate=$(shell date +%Y-%m-%d) $(LDFLAGS)
ifdef GH_OAUTH_CLIENT_SECRET
LDFLAGS := -X github.com/github/gh-cli/context.oauthClientID=$(GH_OAUTH_CLIENT_ID) $(LDFLAGS)
LDFLAGS := -X github.com/github/gh-cli/context.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(LDFLAGS)
endif
bin/gh: $(BUILD_FILES)
go build -o "$@"
@go build -ldflags "$(LDFLAGS)" -o "$@"
test:
go test ./...

View file

@ -2,6 +2,7 @@ package api
import (
"fmt"
"time"
)
type PullRequestsPayload struct {
@ -13,6 +14,7 @@ type PullRequestsPayload struct {
type PullRequest struct {
Number int
Title string
State string
URL string
HeadRefName string
}
@ -22,32 +24,107 @@ type Repo interface {
RepoOwner() string
}
func GitHubRepoId(client *Client, ghRepo Repo) (string, error) {
owner := ghRepo.RepoOwner()
repo := ghRepo.RepoName()
type IssuesPayload struct {
Assigned []Issue
Mentioned []Issue
Recent []Issue
}
type Issue struct {
Number int
Title string
URL string
}
func Issues(client *Client, ghRepo Repo, currentUsername string) (*IssuesPayload, error) {
type issues struct {
Issues struct {
Edges []struct {
Node Issue
}
}
}
type response struct {
Assigned issues
Mentioned issues
Recent issues
}
query := `
query FindRepoID($owner:String!, $name:String!) {
repository(owner:$owner, name:$name) {
id
}
}`
fragment issue on Issue {
number
title
}
query($owner: String!, $repo: String!, $since: DateTime!, $viewer: String!, $per_page: Int = 10) {
assigned: repository(owner: $owner, name: $repo) {
issues(filterBy: {assignee: $viewer}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) {
edges {
node {
...issue
}
}
}
}
mentioned: repository(owner: $owner, name: $repo) {
issues(filterBy: {mentioned: $viewer}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) {
edges {
node {
...issue
}
}
}
}
recent: repository(owner: $owner, name: $repo) {
issues(filterBy: {since: $since}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) {
edges {
node {
...issue
}
}
}
}
}
`
owner := ghRepo.RepoOwner()
repo := ghRepo.RepoName()
since := time.Now().UTC().Add(time.Hour * -24).Format("2006-01-02T15:04:05-0700")
variables := map[string]interface{}{
"owner": owner,
"name": repo,
"owner": owner,
"repo": repo,
"viewer": currentUsername,
"since": since,
}
result := struct {
Repository struct {
Id string
}
}{}
err := client.GraphQL(query, variables, &result)
var resp response
err := client.GraphQL(query, variables, &resp)
if err != nil {
return "", fmt.Errorf("failed to determine GH repo ID: %s", err)
return nil, err
}
return result.Repository.Id, nil
var assigned []Issue
for _, edge := range resp.Assigned.Issues.Edges {
assigned = append(assigned, edge.Node)
}
var mentioned []Issue
for _, edge := range resp.Mentioned.Issues.Edges {
mentioned = append(mentioned, edge.Node)
}
var recent []Issue
for _, edge := range resp.Recent.Issues.Edges {
recent = append(recent, edge.Node)
}
payload := IssuesPayload{
assigned,
mentioned,
recent,
}
return &payload, nil
}
func PullRequests(client *Client, ghRepo Repo, currentBranch, currentUsername string) (*PullRequestsPayload, error) {
@ -242,3 +319,96 @@ func CreatePullRequest(client *Client, ghRepo Repo, title string, body string, d
return result.CreatePullRequest.PullRequest.URL, nil
}
func PullRequestList(client *Client, vars map[string]interface{}, limit int) ([]PullRequest, error) {
type response struct {
Repository struct {
PullRequests struct {
Edges []struct {
Node PullRequest
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
}
}
query := `
query(
$owner: String!,
$repo: String!,
$limit: Int!,
$endCursor: String,
$baseBranch: String,
$labels: [String!],
$state: [PullRequestState!] = OPEN
) {
repository(owner: $owner, name: $repo) {
pullRequests(
states: $state,
baseRefName: $baseBranch,
labels: $labels,
first: $limit,
after: $endCursor,
orderBy: {field: CREATED_AT, direction: DESC}
) {
edges {
node {
number
title
state
url
headRefName
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}`
prs := []PullRequest{}
pageLimit := min(limit, 100)
variables := map[string]interface{}{}
for name, val := range vars {
variables[name] = val
}
for {
variables["limit"] = pageLimit
var data response
err := client.GraphQL(query, variables, &data)
if err != nil {
return nil, err
}
prData := data.Repository.PullRequests
for _, edge := range prData.Edges {
prs = append(prs, edge.Node)
if len(prs) == limit {
goto done
}
}
if prData.PageInfo.HasNextPage {
variables["endCursor"] = prData.PageInfo.EndCursor
pageLimit = min(pageLimit, limit-len(prs))
continue
}
done:
break
}
return prs, nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

40
api/queries_issue.go Normal file
View file

@ -0,0 +1,40 @@
package api
func IssueCreate(client *Client, ghRepo Repo, params map[string]interface{}) (*Issue, error) {
repoID, err := GitHubRepoId(client, ghRepo)
if err != nil {
return nil, err
}
query := `
mutation CreateIssue($input: CreateIssueInput!) {
createIssue(input: $input) {
issue {
url
}
}
}`
inputParams := map[string]interface{}{
"repositoryId": repoID,
}
for key, val := range params {
inputParams[key] = val
}
variables := map[string]interface{}{
"input": inputParams,
}
result := struct {
CreateIssue struct {
Issue Issue
}
}{}
err = client.GraphQL(query, variables, &result)
if err != nil {
return nil, err
}
return &result.CreateIssue.Issue, nil
}

31
api/queries_repo.go Normal file
View file

@ -0,0 +1,31 @@
package api
import "fmt"
func GitHubRepoId(client *Client, ghRepo Repo) (string, error) {
owner := ghRepo.RepoOwner()
repo := ghRepo.RepoName()
query := `
query FindRepoID($owner:String!, $name:String!) {
repository(owner:$owner, name:$name) {
id
}
}`
variables := map[string]interface{}{
"owner": owner,
"name": repo,
}
result := struct {
Repository struct {
Id string
}
}{}
err := client.GraphQL(query, variables, &result)
if err != nil || result.Repository.Id == "" {
return "", fmt.Errorf("failed to determine GH repo ID: %s", err)
}
return result.Repository.Id, nil
}

43
command/completion.go Normal file
View file

@ -0,0 +1,43 @@
package command
import (
"fmt"
"github.com/spf13/cobra"
)
func init() {
RootCmd.AddCommand(completionCmd)
completionCmd.Flags().StringP("shell", "s", "bash", "The type of shell")
}
var completionCmd = &cobra.Command{
Use: "completion",
Hidden: true,
Short: "Generates completion scripts",
Long: `To enable completion in your shell, run:
eval "$(gh completion)"
You can add that to your '~/.bash_profile' to enable completion whenever you
start a new shell.
When installing with Homebrew, see https://docs.brew.sh/Shell-Completion
`,
RunE: func(cmd *cobra.Command, args []string) error {
shellType, err := cmd.Flags().GetString("shell")
if err != nil {
return err
}
switch shellType {
case "bash":
RootCmd.GenBashCompletion(cmd.OutOrStdout())
case "zsh":
RootCmd.GenZshCompletion(cmd.OutOrStdout())
default:
return fmt.Errorf("unsupported shell type: %s", shellType)
}
return nil
},
}

View file

@ -0,0 +1,50 @@
package command
import (
"bytes"
"strings"
"testing"
)
func TestCompletion_bash(t *testing.T) {
out := bytes.Buffer{}
completionCmd.SetOut(&out)
RootCmd.SetArgs([]string{"completion"})
_, err := RootCmd.ExecuteC()
if err != nil {
t.Fatal(err)
}
outStr := out.String()
if !strings.Contains(outStr, "complete -o default -F __start_gh gh") {
t.Errorf("problem in bash completion:\n%s", outStr)
}
}
func TestCompletion_zsh(t *testing.T) {
out := bytes.Buffer{}
completionCmd.SetOut(&out)
RootCmd.SetArgs([]string{"completion", "-s", "zsh"})
_, err := RootCmd.ExecuteC()
if err != nil {
t.Fatal(err)
}
outStr := out.String()
if !strings.Contains(outStr, "#compdef _gh gh") {
t.Errorf("problem in zsh completion:\n%s", outStr)
}
}
func TestCompletion_unsupported(t *testing.T) {
out := bytes.Buffer{}
completionCmd.SetOut(&out)
RootCmd.SetArgs([]string{"completion", "-s", "fish"})
_, err := RootCmd.ExecuteC()
if err == nil || err.Error() != "unsupported shell type: fish" {
t.Fatal(err)
}
}

192
command/issue.go Normal file
View file

@ -0,0 +1,192 @@
package command
import (
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
"github.com/github/gh-cli/api"
"github.com/github/gh-cli/utils"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh/terminal"
)
func init() {
RootCmd.AddCommand(issueCmd)
issueCmd.AddCommand(
&cobra.Command{
Use: "status",
Short: "Show status of relevant issues",
RunE: issueList,
},
&cobra.Command{
Use: "view <issue-number>",
Args: cobra.MinimumNArgs(1),
Short: "Open an issue in the browser",
RunE: issueView,
},
)
issueCmd.AddCommand(issueCreateCmd)
issueCreateCmd.Flags().StringArrayP("message", "m", nil, "set title and body")
issueCreateCmd.Flags().BoolP("web", "w", false, "open the web browser to create an issue")
}
var issueCmd = &cobra.Command{
Use: "issue",
Short: "Work with GitHub issues",
Long: `Helps you work with issues.`,
}
var issueCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new issue",
RunE: issueCreate,
}
func issueList(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
baseRepo, err := ctx.BaseRepo()
if err != nil {
return err
}
currentUser, err := ctx.AuthLogin()
if err != nil {
return err
}
issuePayload, err := api.Issues(apiClient, baseRepo, currentUser)
if err != nil {
return err
}
printHeader("Issues assigned to you")
if issuePayload.Assigned != nil {
printIssues(issuePayload.Assigned...)
} else {
message := fmt.Sprintf(" There are no issues assgined to you")
printMessage(message)
}
fmt.Println()
printHeader("Issues mentioning you")
if len(issuePayload.Mentioned) > 0 {
printIssues(issuePayload.Mentioned...)
} else {
printMessage(" There are no issues mentioning you")
}
fmt.Println()
printHeader("Recent issues")
if len(issuePayload.Recent) > 0 {
printIssues(issuePayload.Recent...)
} else {
printMessage(" There are no recent issues")
}
fmt.Println()
return nil
}
func issueView(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
baseRepo, err := ctx.BaseRepo()
if err != nil {
return err
}
var openURL string
if number, err := strconv.Atoi(args[0]); err == nil {
// TODO: move URL generation into GitHubRepository
openURL = fmt.Sprintf("https://github.com/%s/%s/issues/%d", baseRepo.RepoOwner(), baseRepo.RepoName(), number)
} else {
return fmt.Errorf("invalid issue number: '%s'", args[0])
}
fmt.Printf("Opening %s in your browser.\n", openURL)
return utils.OpenInBrowser(openURL)
}
func issueCreate(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
baseRepo, err := ctx.BaseRepo()
if err != nil {
return err
}
if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb {
// TODO: move URL generation into GitHubRepository
openURL := fmt.Sprintf("https://github.com/%s/%s/issues/new", baseRepo.RepoOwner(), baseRepo.RepoName())
// TODO: figure out how to stub this in tests
if stat, err := os.Stat(".github/ISSUE_TEMPLATE"); err == nil && stat.IsDir() {
openURL += "/choose"
}
return utils.OpenInBrowser(openURL)
}
var title string
var body string
message, err := cmd.Flags().GetStringArray("message")
if err != nil {
return err
}
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
if len(message) > 0 {
title = message[0]
body = strings.Join(message[1:], "\n\n")
} else {
// TODO: open the text editor for issue title & body
input := os.Stdin
if terminal.IsTerminal(int(input.Fd())) {
cmd.Println("Enter the issue title and body; press Enter + Ctrl-D when done:")
}
inputBytes, err := ioutil.ReadAll(input)
if err != nil {
return err
}
parts := strings.SplitN(string(inputBytes), "\n\n", 2)
if len(parts) > 0 {
title = parts[0]
}
if len(parts) > 1 {
body = parts[1]
}
}
if title == "" {
return fmt.Errorf("aborting due to empty title")
}
params := map[string]interface{}{
"title": title,
"body": body,
}
newIssue, err := api.IssueCreate(apiClient, baseRepo, params)
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), newIssue.URL)
return nil
}
func printIssues(issues ...api.Issue) {
for _, issue := range issues {
fmt.Printf(" #%d %s\n", issue.Number, truncate(70, issue.Title))
}
}

117
command/issue_test.go Normal file
View file

@ -0,0 +1,117 @@
package command
import (
"bytes"
"encoding/json"
"io/ioutil"
"os"
"os/exec"
"regexp"
"testing"
"github.com/github/gh-cli/test"
"github.com/github/gh-cli/utils"
)
func TestIssueStatus(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
jsonFile, _ := os.Open("../test/fixtures/issueStatus.json")
defer jsonFile.Close()
http.StubResponse(200, jsonFile)
output, err := test.RunCommand(RootCmd, "issue status")
if err != nil {
t.Errorf("error running command `issue status`: %v", err)
}
expectedIssues := []*regexp.Regexp{
regexp.MustCompile(`#8.*carrots`),
regexp.MustCompile(`#9.*squash`),
regexp.MustCompile(`#10.*broccoli`),
regexp.MustCompile(`#11.*swiss chard`),
}
for _, r := range expectedIssues {
if !r.MatchString(output) {
t.Errorf("output did not match regexp /%s/", r)
}
}
}
func TestIssueView(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
jsonFile, _ := os.Open("../test/fixtures/issueView.json")
defer jsonFile.Close()
http.StubResponse(200, jsonFile)
var seenCmd *exec.Cmd
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
seenCmd = cmd
return &outputStub{}
})
defer restoreCmd()
output, err := test.RunCommand(RootCmd, "issue view 8")
if err != nil {
t.Errorf("error running command `issue view`: %v", err)
}
if output == "" {
t.Errorf("command output expected got an empty string")
}
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
if url != "https://github.com/OWNER/REPO/issues/8" {
t.Errorf("got: %q", url)
}
}
func TestIssueCreate(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"id": "REPOID"
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createIssue": { "issue": {
"URL": "https://github.com/OWNER/REPO/issues/12"
} } } }
`))
out := bytes.Buffer{}
issueCreateCmd.SetOut(&out)
RootCmd.SetArgs([]string{"issue", "create", "-m", "hello", "-m", "ab", "-m", "cd"})
_, err := RootCmd.ExecuteC()
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
reqBody := struct {
Variables struct {
Input struct {
RepositoryID string
Title string
Body string
}
}
}{}
json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
eq(t, reqBody.Variables.Input.Title, "hello")
eq(t, reqBody.Variables.Input.Body, "ab\n\ncd")
eq(t, out.String(), "https://github.com/OWNER/REPO/issues/12\n")
}

View file

@ -2,42 +2,50 @@ package command
import (
"fmt"
"os"
"strconv"
"github.com/github/gh-cli/api"
"github.com/github/gh-cli/utils"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh/terminal"
)
func init() {
RootCmd.AddCommand(prCmd)
prCmd.AddCommand(
&cobra.Command{
Use: "list",
Short: "List pull requests",
RunE: prList,
},
&cobra.Command{
Use: "view [pr-number]",
Short: "Open a pull request in the browser",
RunE: prView,
},
prCreateCmd,
)
prCmd.AddCommand(prCreateCmd)
prCmd.AddCommand(prListCmd)
prCmd.AddCommand(prStatusCmd)
prCmd.AddCommand(prViewCmd)
prListCmd.Flags().IntP("limit", "L", 30, "maximum number of items to fetch")
prListCmd.Flags().StringP("state", "s", "open", "filter by state")
prListCmd.Flags().StringP("base", "b", "", "filter by base branch")
prListCmd.Flags().StringArrayP("label", "l", nil, "filter by label")
}
var prCmd = &cobra.Command{
Use: "pr",
Short: "Work with pull requests",
Long: `This command allows you to
work with pull requests.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("%+v is not a valid PR command", args)
},
Long: `Helps you work with pull requests.`,
}
var prListCmd = &cobra.Command{
Use: "list",
Short: "List pull requests",
RunE: prList,
}
var prStatusCmd = &cobra.Command{
Use: "status",
Short: "Show status of relevant pull requests",
RunE: prStatus,
}
var prViewCmd = &cobra.Command{
Use: "view [pr-number]",
Short: "Open a pull request in the browser",
RunE: prView,
}
func prList(cmd *cobra.Command, args []string) error {
func prStatus(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
@ -90,6 +98,117 @@ func prList(cmd *cobra.Command, args []string) error {
return nil
}
func prList(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
baseRepo, err := ctx.BaseRepo()
if err != nil {
return err
}
limit, err := cmd.Flags().GetInt("limit")
if err != nil {
return err
}
state, err := cmd.Flags().GetString("state")
if err != nil {
return err
}
baseBranch, err := cmd.Flags().GetString("base")
if err != nil {
return err
}
labels, err := cmd.Flags().GetStringArray("label")
if err != nil {
return err
}
var graphqlState []string
switch state {
case "open":
graphqlState = []string{"OPEN"}
case "closed":
graphqlState = []string{"CLOSED"}
case "merged":
graphqlState = []string{"MERGED"}
case "all":
graphqlState = []string{"OPEN", "CLOSED", "MERGED"}
default:
return fmt.Errorf("invalid state: %s", state)
}
params := map[string]interface{}{
"owner": baseRepo.RepoOwner(),
"repo": baseRepo.RepoName(),
"state": graphqlState,
}
if len(labels) > 0 {
params["labels"] = labels
}
if baseBranch != "" {
params["baseBranch"] = baseBranch
}
prs, err := api.PullRequestList(apiClient, params, limit)
if err != nil {
return err
}
tty := false
ttyWidth := 80
out := cmd.OutOrStdout()
if outFile, isFile := out.(*os.File); isFile {
fd := int(outFile.Fd())
tty = terminal.IsTerminal(fd)
if w, _, err := terminal.GetSize(fd); err == nil {
ttyWidth = w
}
}
numWidth := 0
maxTitleWidth := 0
for _, pr := range prs {
numLen := len(strconv.Itoa(pr.Number)) + 1
if numLen > numWidth {
numWidth = numLen
}
if len(pr.Title) > maxTitleWidth {
maxTitleWidth = len(pr.Title)
}
}
branchWidth := 40
titleWidth := ttyWidth - branchWidth - 2 - numWidth - 2
if maxTitleWidth < titleWidth {
branchWidth += titleWidth - maxTitleWidth
titleWidth = maxTitleWidth
}
for _, pr := range prs {
if tty {
prNum := fmt.Sprintf("% *s", numWidth, fmt.Sprintf("#%d", pr.Number))
switch pr.State {
case "OPEN":
prNum = utils.Green(prNum)
case "CLOSED":
prNum = utils.Red(prNum)
case "MERGED":
prNum = utils.Magenta(prNum)
}
prBranch := utils.Cyan(truncate(branchWidth, pr.HeadRefName))
fmt.Fprintf(out, "%s %-*s %s\n", prNum, titleWidth, truncate(titleWidth, pr.Title), prBranch)
} else {
fmt.Fprintf(out, "%d\t%s\t%s\n", pr.Number, pr.Title, pr.HeadRefName)
}
}
return nil
}
func prView(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
baseRepo, err := ctx.BaseRepo()
@ -130,7 +249,7 @@ func prView(cmd *cobra.Command, args []string) error {
func printPrs(prs ...api.PullRequest) {
for _, pr := range prs {
fmt.Printf(" #%d %s %s\n", pr.Number, truncateTitle(pr.Title), utils.Cyan("["+pr.HeadRefName+"]"))
fmt.Printf(" #%d %s %s\n", pr.Number, truncate(50, pr.Title), utils.Cyan("["+pr.HeadRefName+"]"))
}
}
@ -142,9 +261,7 @@ func printMessage(s string) {
fmt.Println(utils.Gray(s))
}
func truncateTitle(title string) string {
const maxLength = 50
func truncate(maxLength int, title string) string {
if len(title) > maxLength {
return title[0:maxLength-3] + "..."
}

View file

@ -1,44 +1,37 @@
package command
import (
"bytes"
"encoding/json"
"io/ioutil"
"os"
"os/exec"
"reflect"
"regexp"
"testing"
"github.com/github/gh-cli/api"
"github.com/github/gh-cli/context"
"github.com/github/gh-cli/test"
"github.com/github/gh-cli/utils"
)
func initBlankContext(repo, branch string) {
initContext = func() context.Context {
ctx := context.NewBlank()
ctx.SetBaseRepo(repo)
ctx.SetBranch(branch)
return ctx
func eq(t *testing.T, got interface{}, expected interface{}) {
t.Helper()
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected: %v, got: %v", expected, got)
}
}
func initFakeHTTP() *api.FakeHTTP {
http := &api.FakeHTTP{}
apiClientForContext = func(context.Context) (*api.Client, error) {
return api.NewClient(api.ReplaceTripper(http)), nil
}
return http
}
func TestPRList(t *testing.T) {
func TestPRStatus(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
jsonFile, _ := os.Open("../test/fixtures/prList.json")
jsonFile, _ := os.Open("../test/fixtures/prStatus.json")
defer jsonFile.Close()
http.StubResponse(200, jsonFile)
output, err := test.RunCommand(RootCmd, "pr list")
output, err := test.RunCommand(RootCmd, "pr status")
if err != nil {
t.Errorf("error running command `pr list`: %v", err)
t.Errorf("error running command `pr status`: %v", err)
}
expectedPrs := []*regexp.Regexp{
@ -55,6 +48,57 @@ func TestPRList(t *testing.T) {
}
}
func TestPRList(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
jsonFile, _ := os.Open("../test/fixtures/prList.json")
defer jsonFile.Close()
http.StubResponse(200, jsonFile)
out := bytes.Buffer{}
prListCmd.SetOut(&out)
RootCmd.SetArgs([]string{"pr", "list"})
_, err := RootCmd.ExecuteC()
if err != nil {
t.Fatal(err)
}
eq(t, out.String(), `32 New feature feature
29 Fixed bad bug bug-fix
28 Improve documentation docs
`)
}
func TestPRList_filtering(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
respBody := bytes.NewBufferString(`{ "data": {} }`)
http.StubResponse(200, respBody)
prListCmd.SetOut(ioutil.Discard)
RootCmd.SetArgs([]string{"pr", "list", "-s", "all", "-l", "one", "-l", "two"})
_, err := RootCmd.ExecuteC()
if err != nil {
t.Fatal(err)
}
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
reqBody := struct {
Variables struct {
State []string
Labels []string
}
}{}
json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.State, []string{"OPEN", "CLOSED", "MERGED"})
eq(t, reqBody.Variables.Labels, []string{"one", "two"})
}
func TestPRView(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
@ -63,8 +107,12 @@ func TestPRView(t *testing.T) {
defer jsonFile.Close()
http.StubResponse(200, jsonFile)
teardown, callCount := mockOpenInBrowser()
defer teardown()
var seenCmd *exec.Cmd
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
seenCmd = cmd
return &outputStub{}
})
defer restoreCmd()
output, err := test.RunCommand(RootCmd, "pr view")
if err != nil {
@ -75,8 +123,12 @@ func TestPRView(t *testing.T) {
t.Errorf("command output expected got an empty string")
}
if *callCount != 1 {
t.Errorf("OpenInBrowser should be called 1 time but was called %d time(s)", *callCount)
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
if url != "https://github.com/OWNER/REPO/pull/10" {
t.Errorf("got: %q", url)
}
}
@ -88,16 +140,20 @@ func TestPRView_NoActiveBranch(t *testing.T) {
defer jsonFile.Close()
http.StubResponse(200, jsonFile)
teardown, callCount := mockOpenInBrowser()
defer teardown()
var seenCmd *exec.Cmd
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
seenCmd = cmd
return &outputStub{}
})
defer restoreCmd()
output, err := test.RunCommand(RootCmd, "pr view")
if err == nil || err.Error() != "the 'master' branch has no open pull requests" {
t.Errorf("error running command `pr view`: %v", err)
}
if *callCount > 0 {
t.Errorf("OpenInBrowser should NOT be called but was called %d time(s)", *callCount)
if seenCmd != nil {
t.Fatalf("unexpected command: %v", seenCmd.Args)
}
// Now run again but provide a PR number
@ -110,22 +166,11 @@ func TestPRView_NoActiveBranch(t *testing.T) {
t.Errorf("command output expected got an empty string")
}
if *callCount != 1 {
t.Errorf("OpenInBrowser should be called once but was called %d time(s)", *callCount)
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
if url != "https://github.com/OWNER/REPO/pull/23" {
t.Errorf("got: %q", url)
}
}
func mockOpenInBrowser() (func(), *int) {
callCount := 0
originalOpenInBrowser := utils.OpenInBrowser
teardown := func() {
utils.OpenInBrowser = originalOpenInBrowser
}
utils.OpenInBrowser = func(_ string) error {
callCount++
return nil
}
return teardown, &callCount
}

View file

@ -6,16 +6,31 @@ import (
"github.com/github/gh-cli/api"
"github.com/github/gh-cli/context"
"github.com/github/gh-cli/version"
"github.com/spf13/cobra"
)
// Version is dynamically set at build time in the Makefile
var Version = "DEV"
// BuildDate is dynamically set at build time in the Makefile
var BuildDate = "YYYY-MM-DD"
func init() {
RootCmd.Version = fmt.Sprintf("%s (%s)", Version, BuildDate)
RootCmd.PersistentFlags().StringP("repo", "R", "", "current GitHub repository")
RootCmd.PersistentFlags().StringP("current-branch", "B", "", "current git branch")
// TODO:
// RootCmd.PersistentFlags().BoolP("verbose", "V", false, "enable verbose output")
RootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
return FlagError{err}
})
}
// FlagError is the kind of error raised in flag processing
type FlagError struct {
error
}
// RootCmd is the entry point of command-line execution
@ -23,10 +38,9 @@ var RootCmd = &cobra.Command{
Use: "gh",
Short: "GitHub CLI",
Long: `Do things with GitHub from your terminal`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("root")
},
SilenceErrors: true,
SilenceUsage: true,
}
// overriden in tests
@ -57,7 +71,7 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) {
}
opts := []api.ClientOption{
api.AddHeader("Authorization", fmt.Sprintf("token %s", token)),
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", version.Version)),
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)),
api.AddHeader("Accept", "application/vnd.github.shadow-cat-preview+json"),
}
if verbose := os.Getenv("DEBUG"); verbose != "" {

36
command/testing.go Normal file
View file

@ -0,0 +1,36 @@
package command
import (
"github.com/github/gh-cli/api"
"github.com/github/gh-cli/context"
)
func initBlankContext(repo, branch string) {
initContext = func() context.Context {
ctx := context.NewBlank()
ctx.SetBaseRepo(repo)
ctx.SetBranch(branch)
return ctx
}
}
func initFakeHTTP() *api.FakeHTTP {
http := &api.FakeHTTP{}
apiClientForContext = func(context.Context) (*api.Client, error) {
return api.NewClient(api.ReplaceTripper(http)), nil
}
return http
}
// outputStub implements a simple utils.Runnable
type outputStub struct {
output []byte
}
func (s outputStub) Output() ([]byte, error) {
return s.output, nil
}
func (s outputStub) Run() error {
return nil
}

View file

@ -14,6 +14,9 @@ import (
const (
oauthHost = "github.com"
)
var (
// The GitHub app that is meant for development
oauthClientID = "4d747ba5675d5d66553f"
// This value is safe to be embedded in version control

View file

@ -8,12 +8,13 @@ import (
"os/exec"
"path/filepath"
"strings"
"github.com/github/gh-cli/utils"
)
func Dir() (string, error) {
dirCmd := exec.Command("git", "rev-parse", "-q", "--git-dir")
dirCmd.Stderr = nil
output, err := dirCmd.Output()
output, err := utils.PrepareCmd(dirCmd).Output()
if err != nil {
return "", fmt.Errorf("Not a git repository (or any of the parent directories): .git")
}
@ -33,7 +34,7 @@ func Dir() (string, error) {
func WorkdirName() (string, error) {
toplevelCmd := exec.Command("git", "rev-parse", "--show-toplevel")
toplevelCmd.Stderr = nil
output, err := toplevelCmd.Output()
output, err := utils.PrepareCmd(toplevelCmd).Output()
dir := firstLine(output)
if dir == "" {
return "", fmt.Errorf("unable to determine git working directory")
@ -44,8 +45,7 @@ func WorkdirName() (string, error) {
func HasFile(segments ...string) bool {
// The blessed way to resolve paths within git dir since Git 2.5.0
pathCmd := exec.Command("git", "rev-parse", "-q", "--git-path", filepath.Join(segments...))
pathCmd.Stderr = nil
if output, err := pathCmd.Output(); err == nil {
if output, err := utils.PrepareCmd(pathCmd).Output(); err == nil {
if lines := outputLines(output); len(lines) == 1 {
if _, err := os.Stat(lines[0]); err == nil {
return true
@ -97,8 +97,7 @@ func BranchAtRef(paths ...string) (name string, err error) {
func Editor() (string, error) {
varCmd := exec.Command("git", "var", "GIT_EDITOR")
varCmd.Stderr = nil
output, err := varCmd.Output()
output, err := utils.PrepareCmd(varCmd).Output()
if err != nil {
return "", fmt.Errorf("Can't load git var: GIT_EDITOR")
}
@ -112,8 +111,7 @@ func Head() (string, error) {
func SymbolicFullName(name string) (string, error) {
parseCmd := exec.Command("git", "rev-parse", "--symbolic-full-name", name)
parseCmd.Stderr = nil
output, err := parseCmd.Output()
output, err := utils.PrepareCmd(parseCmd).Output()
if err != nil {
return "", fmt.Errorf("Unknown revision or path not in the working tree: %s", name)
}
@ -145,9 +143,7 @@ func CommentChar(text string) (string, error) {
func Show(sha string) (string, error) {
cmd := exec.Command("git", "-c", "log.showSignature=false", "show", "-s", "--format=%s%n%+b", sha)
cmd.Stderr = nil
output, err := cmd.Output()
output, err := utils.PrepareCmd(cmd).Output()
return strings.TrimSpace(string(output)), err
}
@ -157,7 +153,7 @@ func Log(sha1, sha2 string) (string, error) {
"-c", "log.showSignature=false", "log", "--no-color",
"--format=%h (%aN, %ar)%n%w(78,3,3)%s%n%+b",
"--cherry", shaRange)
outputs, err := cmd.Output()
outputs, err := utils.PrepareCmd(cmd).Output()
if err != nil {
return "", fmt.Errorf("Can't load git log %s..%s", sha1, sha2)
}
@ -167,14 +163,13 @@ func Log(sha1, sha2 string) (string, error) {
func listRemotes() ([]string, error) {
remoteCmd := exec.Command("git", "remote", "-v")
remoteCmd.Stderr = nil
output, err := remoteCmd.Output()
output, err := utils.PrepareCmd(remoteCmd).Output()
return outputLines(output), err
}
func Config(name string) (string, error) {
configCmd := exec.Command("git", "config", name)
output, err := configCmd.Output()
output, err := utils.PrepareCmd(configCmd).Output()
if err != nil {
return "", fmt.Errorf("unknown config key: %s", name)
}
@ -190,21 +185,16 @@ func ConfigAll(name string) ([]string, error) {
}
configCmd := exec.Command("git", "config", mode, name)
output, err := configCmd.Output()
output, err := utils.PrepareCmd(configCmd).Output()
if err != nil {
return nil, fmt.Errorf("Unknown config %s", name)
}
return outputLines(output), nil
}
func Run(args ...string) error {
cmd := exec.Command("git", args...)
return cmd.Run()
}
func LocalBranches() ([]string, error) {
branchesCmd := exec.Command("git", "branch", "--list")
output, err := branchesCmd.Output()
output, err := utils.PrepareCmd(branchesCmd).Output()
if err != nil {
return nil, err
}
@ -246,9 +236,6 @@ func Push(remote string, ref string) error {
func outputLines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
if lines == "" {
return []string{}
}
return strings.Split(lines, "\n")
}

4
go.mod
View file

@ -4,11 +4,13 @@ go 1.13
require (
github.com/AlecAivazis/survey/v2 v2.0.4
github.com/gookit/color v1.2.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.2
github.com/mattn/go-isatty v0.0.9
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b
github.com/mitchellh/go-homedir v1.1.0
github.com/spf13/cobra v0.0.5
github.com/stretchr/testify v1.3.0 // indirect
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5
gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652
)

8
go.sum
View file

@ -1,8 +1,8 @@
github.com/AlecAivazis/survey v1.8.7 h1:QIBq36/0wfYpXxdBqDXNAjKHx1bKnRGu/EDnva27k84=
github.com/AlecAivazis/survey/v2 v2.0.4 h1:qzXnJSzXEvmUllWqMBWpZndvT2YfoAUzAMvZUax3L2M=
github.com/AlecAivazis/survey/v2 v2.0.4/go.mod h1:WYBhg6f0y/fNYUuesWQc0PKbJcEliGcYHB9sNT3Bg74=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
@ -13,14 +13,14 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gookit/color v1.2.0 h1:lHA77Kuyi5JpBnA9ESvwkY+nanLjRZ0mHbWQXRYk2Lk=
github.com/gookit/color v1.2.0/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ=
github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
@ -52,8 +52,10 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 h1:8dUaAV7K4uHsF56JQWkprecIQKdPHtR9jCHF5nB8uzc=
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

View file

@ -3,13 +3,18 @@ package main
import (
"fmt"
"os"
"strings"
"github.com/github/gh-cli/command"
)
func main() {
if err := command.RootCmd.Execute(); err != nil {
fmt.Println(err)
if cmd, err := command.RootCmd.ExecuteC(); err != nil {
fmt.Fprintln(os.Stderr, err)
_, isFlagError := err.(command.FlagError)
if isFlagError || strings.HasPrefix(err.Error(), "unknown command ") {
fmt.Fprintln(os.Stderr, cmd.UsageString())
}
os.Exit(1)
}
}

47
test/fixtures/issueList.json vendored Normal file
View file

@ -0,0 +1,47 @@
{
"data": {
"assigned": {
"issues": {
"edges": [
{
"node": {
"number": 9,
"title": "corey thinks squash tastes bad"
}
},
{
"node": {
"number": 10,
"title": "broccoli is a superfood"
}
}
]
}
},
"mentioned": {
"issues": {
"edges": [
{
"node": {
"number": 8,
"title": "rabbits eat carrots"
}
},
{
"node": {
"number": 11,
"title": "swiss chard is neutral"
}
}
]
}
},
"recent": {
"issues": {
"edges": []
}
},
"pageInfo": { "hasNextPage": false }
}
}

47
test/fixtures/issueStatus.json vendored Normal file
View file

@ -0,0 +1,47 @@
{
"data": {
"assigned": {
"issues": {
"edges": [
{
"node": {
"number": 9,
"title": "corey thinks squash tastes bad"
}
},
{
"node": {
"number": 10,
"title": "broccoli is a superfood"
}
}
]
}
},
"mentioned": {
"issues": {
"edges": [
{
"node": {
"number": 8,
"title": "rabbits eat carrots"
}
},
{
"node": {
"number": 11,
"title": "swiss chard is neutral"
}
}
]
}
},
"recent": {
"issues": {
"edges": []
}
},
"pageInfo": { "hasNextPage": false }
}
}

36
test/fixtures/issueView.json vendored Normal file
View file

@ -0,0 +1,36 @@
{
"data": {
"repository": {
"issues": {
"edges": [
{
"node": {
"number": 8,
"title": "rabbits eat carrots",
"url": "https://github.com/github/gh-cli/pull/10"
}
},
{
"node": {
"number": 9,
"title": "corey thinks squash tastes bad"
}
},
{
"node": {
"number": 10,
"title": "broccoli is a superfood"
}
},
{
"node": {
"number": 11,
"title": "swiss chard is neutral"
}
}
]
}
},
"pageInfo": { "hasNextPage": false }
}
}

View file

@ -1,50 +1,38 @@
{"data":{
"repository": {
"pullRequests": {
"edges": [
{
"node": {
"number": 10,
"title": "Blueberries are a good fruit",
"url": "https://github.com/github/gh-cli/pull/10",
"headRefName": "[blueberries]"
{
"data": {
"repository": {
"pullRequests": {
"edges": [
{
"node": {
"number": 32,
"title": "New feature",
"url": "https://github.com/monalisa/hello/pull/32",
"headRefName": "feature"
}
},
{
"node": {
"number": 29,
"title": "Fixed bad bug",
"url": "https://github.com/monalisa/hello/pull/29",
"headRefName": "bug-fix"
}
},
{
"node": {
"number": 28,
"title": "Improve documentation",
"url": "https://github.com/monalisa/hello/pull/28",
"headRefName": "docs"
}
}
],
"pageInfo": {
"hasNextPage": false,
"endCursor": ""
}
]
}
}
},
"viewerCreated": {
"edges": [
{
"node": {
"number": 8,
"title": "Strawberries are not actually berries",
"url": "https://github.com/github/gh-cli/pull/8",
"headRefName": "[strawberries]"
}
}
],
"pageInfo": { "hasNextPage": false }
},
"reviewRequested": {
"edges": [
{
"node": {
"number": 9,
"title": "Apples are tasty",
"url": "https://github.com/github/gh-cli/pull/9",
"headRefName": "[apples]"
}
},
{
"node": {
"number": 11,
"title": "Figs are my favorite",
"url": "https://github.com/github/gh-cli/pull/1",
"headRefName": "[figs]"
}
}
],
"pageInfo": { "hasNextPage": false }
}
}}
}

50
test/fixtures/prStatus.json vendored Normal file
View file

@ -0,0 +1,50 @@
{"data":{
"repository": {
"pullRequests": {
"edges": [
{
"node": {
"number": 10,
"title": "Blueberries are a good fruit",
"url": "https://github.com/github/gh-cli/pull/10",
"headRefName": "[blueberries]"
}
}
]
}
},
"viewerCreated": {
"edges": [
{
"node": {
"number": 8,
"title": "Strawberries are not actually berries",
"url": "https://github.com/github/gh-cli/pull/8",
"headRefName": "[strawberries]"
}
}
],
"pageInfo": { "hasNextPage": false }
},
"reviewRequested": {
"edges": [
{
"node": {
"number": 9,
"title": "Apples are tasty",
"url": "https://github.com/github/gh-cli/pull/9",
"headRefName": "[apples]"
}
},
{
"node": {
"number": 11,
"title": "Figs are my favorite",
"url": "https://github.com/github/gh-cli/pull/1",
"headRefName": "[figs]"
}
}
],
"pageInfo": { "hasNextPage": false }
}
}}

View file

@ -6,7 +6,7 @@
"node": {
"number": 10,
"title": "Blueberries are a good fruit",
"url": "https://github.com/github/gh-cli/pull/10",
"url": "https://github.com/OWNER/REPO/pull/10",
"headRefName": "[blueberries]"
}
}
@ -19,7 +19,7 @@
"node": {
"number": 8,
"title": "Strawberries are not actually berries",
"url": "https://github.com/github/gh-cli/pull/8",
"url": "https://github.com/OWNER/REPO/pull/8",
"headRefName": "[strawberries]"
}
}
@ -32,7 +32,7 @@
"node": {
"number": 9,
"title": "Apples are tasty",
"url": "https://github.com/github/gh-cli/pull/9",
"url": "https://github.com/OWNER/REPO/pull/9",
"headRefName": "[apples]"
}
},
@ -40,7 +40,7 @@
"node": {
"number": 11,
"title": "Figs are my favorite",
"url": "https://github.com/github/gh-cli/pull/1",
"url": "https://github.com/OWNER/REPO/pull/1",
"headRefName": "[figs]"
}
}

View file

@ -113,6 +113,7 @@ func RunCommand(root *cobra.Command, s string) (string, error) {
root.SetArgs(strings.Split(s, " "))
_, err = root.ExecuteC()
})
if err != nil {
return "", err
}

View file

@ -1,43 +1,24 @@
package utils
import "github.com/gookit/color"
import "github.com/mgutz/ansi"
func Black(a ...interface{}) string {
return color.Black.Render(a...)
var Black = ansi.ColorFunc("black")
var White = ansi.ColorFunc("white")
func Gray(arg string) string {
return ansi.Color(ansi.LightBlack+arg, "")
}
func White(a ...interface{}) string {
return color.White.Render(a...)
}
var Red = ansi.ColorFunc("red")
var Green = ansi.ColorFunc("green")
var Yellow = ansi.ColorFunc("yellow")
var Blue = ansi.ColorFunc("blue")
var Magenta = ansi.ColorFunc("magenta")
var Cyan = ansi.ColorFunc("cyan")
func Gray(a ...interface{}) string {
return color.Gray.Render(a...)
}
func Red(a ...interface{}) string {
return color.Red.Render(a...)
}
func Green(a ...interface{}) string {
return color.Green.Render(a...)
}
func Yellow(a ...interface{}) string {
return color.Yellow.Render(a...)
}
func Blue(a ...interface{}) string {
return color.Blue.Render(a...)
}
func Magenta(a ...interface{}) string {
return color.Magenta.Render(a...)
}
func Cyan(a ...interface{}) string {
return color.Cyan.Render(a...)
}
func Bold(a ...interface{}) string {
return color.Bold.Render(a...)
func Bold(arg string) string {
// This is really annoying. If you just define Bold as ColorFunc("+b") it will properly bold but
// will not use the default color, resulting in black and probably unreadable text. This forces
// the default color before bolding.
return ansi.Color(ansi.DefaultFG+arg, "+b")
}

76
utils/prepare_cmd.go Normal file
View file

@ -0,0 +1,76 @@
package utils
import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
// Runnable is typically an exec.Cmd or its stub in tests
type Runnable interface {
Output() ([]byte, error)
Run() error
}
// PrepareCmd extends exec.Cmd with extra error reporting features and provides a
// hook to stub command execution in tests
var PrepareCmd = func(cmd *exec.Cmd) Runnable {
return &cmdWithStderr{cmd}
}
// SetPrepareCmd overrides PrepareCmd and returns a func to revert it back
func SetPrepareCmd(fn func(*exec.Cmd) Runnable) func() {
origPrepare := PrepareCmd
PrepareCmd = fn
return func() {
PrepareCmd = origPrepare
}
}
// cmdWithStderr augments exec.Cmd by adding stderr to the error message
type cmdWithStderr struct {
*exec.Cmd
}
func (c cmdWithStderr) Output() ([]byte, error) {
if os.Getenv("DEBUG") != "" {
fmt.Fprintf(os.Stderr, "%v\n", c.Cmd.Args)
}
errStream := &bytes.Buffer{}
c.Cmd.Stderr = errStream
out, err := c.Cmd.Output()
if err != nil {
err = &CmdError{errStream, c.Cmd.Args, err}
}
return out, err
}
func (c cmdWithStderr) Run() error {
if os.Getenv("DEBUG") != "" {
fmt.Fprintf(os.Stderr, "%v\n", c.Cmd.Args)
}
errStream := &bytes.Buffer{}
c.Cmd.Stderr = errStream
err := c.Cmd.Run()
if err != nil {
err = &CmdError{errStream, c.Cmd.Args, err}
}
return err
}
// CmdError provides more visibility into why an exec.Cmd had failed
type CmdError struct {
Stderr *bytes.Buffer
Args []string
Err error
}
func (e CmdError) Error() string {
msg := e.Stderr.String()
if msg != "" && !strings.HasSuffix(msg, "\n") {
msg += "\n"
}
return fmt.Sprintf("%s%s: %s", msg, e.Args[0], e.Err)
}

View file

@ -27,7 +27,7 @@ func ConcatPaths(paths ...string) string {
return strings.Join(paths, "/")
}
var OpenInBrowser = func(url string) error {
func OpenInBrowser(url string) error {
browser := os.Getenv("BROWSER")
if browser == "" {
browser = searchBrowserLauncher(runtime.GOOS)
@ -45,7 +45,8 @@ var OpenInBrowser = func(url string) error {
}
endingArgs := append(browserArgs[1:], url)
return exec.Command(browserArgs[0], endingArgs...).Run()
browseCmd := exec.Command(browserArgs[0], endingArgs...)
return PrepareCmd(browseCmd).Run()
}
func searchBrowserLauncher(goos string) (browser string) {

View file

@ -1,11 +0,0 @@
package version
import (
"fmt"
)
var Version = "0.0.0"
func FullVersion() (string, error) {
return fmt.Sprintf("gh version %s", Version), nil
}