├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── package.json ├── src ├── content-from-filename.js ├── index.js └── update-file-with-content.js └── test └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # common 2 | coverage 3 | node_modules 4 | *.log 5 | *.dump 6 | .DS_Store 7 | .nyc_output 8 | .test 9 | .tmp 10 | 11 | # build-artifacts 12 | dist 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # common 2 | coverage 3 | node_modules 4 | *.log 5 | *.dump 6 | .DS_Store 7 | .nyc_output 8 | .test 9 | .tmp 10 | 11 | # source/config 12 | lib 13 | *.yml 14 | .babelrc 15 | .gitignore 16 | .editorconfig 17 | .npmrc 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - 6 10 | before_script: 11 | - npm prune 12 | - 'curl -Lo travis_after_all.py https://git.io/vLSON' 13 | after_success: 14 | - npm run coverage:upload 15 | - python travis_after_all.py 16 | - export $(cat .to_export_back) 17 | - npm run semantic-release 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github-change-remote-file 2 | 3 | | [![Build Status](https://travis-ci.org/boennemann/github-change-remote-file.svg?branch=master)](https://travis-ci.org/boennemann/github-change-remote-file) | [![Coverage Status](https://coveralls.io/repos/boennemann/github-change-remote-file/badge.svg?branch=master&service=github)](https://coveralls.io/github/boennemann/github-change-remote-file?branch=master) | [![Dependency Status](https://david-dm.org/boennemann/github-change-remote-file/master.svg)](https://david-dm.org/boennemann/github-change-remote-file/master) | [![devDependency Status](https://david-dm.org/boennemann/github-change-remote-file/master/dev-status.svg)](https://david-dm.org/boennemann/github-change-remote-file/master#info=devDependencies) | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://github.com/feross/standard) | 4 | | --- | --- | --- | --- | --- | 5 | 6 | This module allows you to change a single file in a repository on GitHub and create a new commit or pull-request from that change. This has little to no overhead, because it doesn't work with a local copy of the git repository – everything is done via the GitHub API. 7 | 8 | ## Examples 9 | 10 | Create a new branch and commit on top of it: 11 | 12 | ```js 13 | githubChangeRemoteFile({ 14 | user: 'boennemann', 15 | repo: 'animals', 16 | filename: 'package.json', 17 | newBranch: 'upgrade-standard', 18 | transform: pkg => { 19 | const parsedPkg = JSON.parse(pkg) 20 | pkg.devDependencies.standard = semver.inc(parsedPkg.devDependencies.standard, 'major') 21 | return JSON.stringify(parsedPkg, null, 2) 22 | }, 23 | token: '' 24 | }) 25 | .then(res => console.log(res)) 26 | .catch(console.log) 27 | ``` 28 | 29 | Create a new commit and push it on top of the (master) branch: 30 | 31 | ```js 32 | githubChangeRemoteFile({ 33 | user: 'boennemann', 34 | repo: 'animals', 35 | filename: 'package.json', 36 | transform: pkg => { 37 | const parsedPkg = JSON.parse(pkg) 38 | pkg.devDependencies.standard = semver.inc(parsedPkg.devDependencies.standard, 'major') 39 | return JSON.stringify(parsedPkg, null, 2) 40 | }, 41 | token: '' 42 | }) 43 | .then(res => console.log(res)) 44 | .catch(console.log) 45 | ``` 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-change-remote-file", 3 | "description": "create a new commit with a changed file", 4 | "main": "dist/index.js", 5 | "scripts": { 6 | "build": "rimraf dist && mkdirp dist && babel src --out-dir dist", 7 | "coverage": "nyc report", 8 | "coverage:upload": "npm run -s coverage -- --reporter=text-lcov | coveralls", 9 | "prepublish": "npm run build", 10 | "pretest:unit": "npm run build", 11 | "test": "npm run test:style && npm run test:unit", 12 | "test:style": "standard", 13 | "test:unit": "nyc tap --no-cov test/*.js", 14 | "semantic-release": "semantic-release pre && npm publish && semantic-release post" 15 | }, 16 | "engines": { 17 | "node": ">=6" 18 | }, 19 | "babel": { 20 | "plugins": [ 21 | "transform-async-to-generator" 22 | ] 23 | }, 24 | "standard": { 25 | "parser": "babel-eslint" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/boennemann/github-change-remote-file.git" 30 | }, 31 | "keywords": [ 32 | "git", 33 | "commit" 34 | ], 35 | "author": "Stephan Bönnemann (http://boennemann.me/)", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/boennemann/github-change-remote-file/issues" 39 | }, 40 | "homepage": "https://github.com/boennemann/github-change-remote-file#readme", 41 | "devDependencies": { 42 | "babel-cli": "^6.11.4", 43 | "babel-eslint": "^6.1.2", 44 | "babel-plugin-transform-async-to-generator": "^6.8.0", 45 | "coveralls": "^2.11.12", 46 | "mkdirp": "^0.5.1", 47 | "nock": "^8.0.0", 48 | "nyc": "^7.1.0", 49 | "rimraf": "^2.5.4", 50 | "semantic-release": "^4.3.5", 51 | "standard": "^7.1.2", 52 | "tap": "^6.3.0" 53 | }, 54 | "dependencies": { 55 | "bluebird": "^3.4.1", 56 | "github": "^2.4.0", 57 | "lodash": "^4.14.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/content-from-filename.js: -------------------------------------------------------------------------------- 1 | const {defaults} = require('lodash') 2 | const {promisify} = require('bluebird') 3 | 4 | module.exports = async function contentFromFilename (github, config) { 5 | const { 6 | branch = 'master', 7 | filename 8 | } = config 9 | 10 | const blob = await promisify(github.repos.getContent)(defaults({ 11 | path: filename, 12 | ref: branch 13 | }, config)) 14 | 15 | if (blob.type !== 'file') throw new Error('Type is not a file') 16 | 17 | return { 18 | content: Buffer.from(blob.content, 'base64').toString(), 19 | commitSha: blob.sha 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const {defaults} = require('lodash') 2 | const GitHubApi = require('github') 3 | const {promisify} = require('bluebird') 4 | 5 | const contentFromFilename = require('./content-from-filename') 6 | const updateFileWithContent = require('./update-file-with-content') 7 | 8 | module.exports = async function (config) { 9 | const { 10 | branch = 'master', 11 | token, 12 | transform, 13 | force 14 | } = config 15 | 16 | let github = config.github 17 | 18 | if (!github) { 19 | github = new GitHubApi() 20 | github.authenticate({type: 'oauth', token}) 21 | } 22 | 23 | const content = await contentFromFilename(github, config) 24 | const newContent = transform(content.content) 25 | 26 | var transformedConfig = {} 27 | if (typeof newContent === 'string') transformedConfig.content = newContent 28 | else transformedConfig = newContent 29 | 30 | const newBranch = transformedConfig.newBranch || config.newBranch 31 | if (newBranch) { 32 | const reference = await promisify(github.gitdata.getReference)(defaults({ 33 | ref: `heads/${branch}` 34 | }, config)) 35 | 36 | await promisify(github.gitdata[force ? 'updateReference' : 'createReference'])(defaults({ 37 | sha: reference.object.sha, 38 | ref: (force ? '' : 'refs/') + `heads/${newBranch}`, 39 | force 40 | }, config)) 41 | } 42 | 43 | return await updateFileWithContent(github, defaults({sha: content.commitSha, branch: newBranch || branch}, transformedConfig, config)) 44 | } 45 | -------------------------------------------------------------------------------- /src/update-file-with-content.js: -------------------------------------------------------------------------------- 1 | const {defaults} = require('lodash') 2 | const {promisify} = require('bluebird') 3 | 4 | module.exports = async function (github, config) { 5 | const { 6 | message = `chore: updated ${config.filename}`, 7 | content, 8 | filename, 9 | committer, 10 | author 11 | } = config 12 | 13 | const response = await promisify(github.repos.updateFile)(defaults({ 14 | path: filename, 15 | message, 16 | content: Buffer.from(content).toString('base64'), 17 | committer: committer || author 18 | }, config)) 19 | 20 | return response.commit 21 | } 22 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const nock = require('nock') 2 | const {test} = require('tap') 3 | 4 | const githubChangeRemoteFile = require('../') 5 | 6 | const user = 'jane' 7 | const repo = 'doe' 8 | const accessToken = 'secret' 9 | 10 | const branch = 'master' 11 | const newBranch = 'test-branch' 12 | 13 | const filename = 'my/package.json' 14 | 15 | const branchSha = 'def' 16 | const fileSha = 'aec' 17 | const newFileSha = 'cea' 18 | 19 | nock('https://api.github.com') 20 | .get(`/repos/${user}/${repo}/git/refs/heads%2F${branch}`) 21 | .query({access_token: accessToken}) 22 | .times(2) 23 | .reply(200, { 24 | object: { 25 | sha: branchSha 26 | } 27 | }) 28 | 29 | .post(`/repos/${user}/${repo}/git/refs`, { 30 | ref: `refs/heads/${newBranch}`, 31 | sha: branchSha 32 | }) 33 | .query({access_token: accessToken}) 34 | .times(1) 35 | .reply(201, {}) 36 | 37 | .patch(`/repos/${user}/${repo}/git/refs/heads%2F${newBranch}`, { 38 | force: true, 39 | sha: branchSha 40 | }) 41 | .query({access_token: accessToken}) 42 | .times(1) 43 | .reply(200, {}) 44 | 45 | .get(`/repos/${user}/${repo}/contents/${filename.replace('/', '%2F')}`) 46 | .query({access_token: accessToken, ref: branch}) 47 | .times(4) 48 | .reply(200, { 49 | content: 'YWJj', 50 | type: 'file', 51 | sha: fileSha 52 | }) 53 | 54 | .put(`/repos/${user}/${repo}/contents/${filename.replace('/', '%2F')}`, { 55 | message: `chore: updated ${filename}`, 56 | content: 'QUJD', 57 | sha: fileSha 58 | }) 59 | .query({access_token: accessToken}) 60 | .times(4) 61 | .reply(200, { 62 | commit: { 63 | sha: newFileSha 64 | } 65 | }) 66 | 67 | test('create branch and commit', (t) => { 68 | t.plan(1) 69 | 70 | githubChangeRemoteFile({ 71 | user, 72 | repo, 73 | filename, 74 | branch, 75 | newBranch, 76 | transform: (input) => input.toUpperCase(), 77 | token: accessToken 78 | }) 79 | .then(res => t.is(res.sha, newFileSha)) 80 | .catch(t.threw) 81 | }) 82 | 83 | test('push commit to branch', (t) => { 84 | t.plan(1) 85 | 86 | githubChangeRemoteFile({ 87 | user, 88 | repo, 89 | filename, 90 | branch, 91 | transform: (input) => input.toUpperCase(), 92 | token: accessToken 93 | }) 94 | .then(res => t.is(res.sha, newFileSha)) 95 | .catch(t.threw) 96 | }) 97 | 98 | test('push commit to branch with force', (t) => { 99 | t.plan(1) 100 | 101 | githubChangeRemoteFile({ 102 | user, 103 | repo, 104 | filename, 105 | branch, 106 | newBranch, 107 | force: true, 108 | transform: (input) => input.toUpperCase(), 109 | token: accessToken 110 | }) 111 | .then(res => t.is(res.sha, newFileSha)) 112 | .catch(t.threw) 113 | }) 114 | 115 | test('create commit and push to master (transform: object)', (t) => { 116 | t.plan(1) 117 | 118 | githubChangeRemoteFile({ 119 | user, 120 | repo, 121 | filename, 122 | transform: (input) => { 123 | return { 124 | content: input.toUpperCase() 125 | } 126 | }, 127 | token: accessToken 128 | }) 129 | .then(res => t.is(res.sha, newFileSha)) 130 | .catch(t.threw) 131 | }) 132 | --------------------------------------------------------------------------------