├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cli.js ├── env.js ├── fixtures ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE └── js │ └── .travis.yml ├── lib ├── githubHelpers.js ├── helpers.js ├── lintPackageJson.js └── robo.js ├── package-lock.json ├── package.json ├── src ├── checkers │ ├── community.js │ ├── javascript.js │ └── travis.js ├── index.js ├── messages.js └── utilities │ ├── find-section.js │ └── get-section.js └── test ├── fixtures ├── CODE_OF_CONDUCT.md └── readme-with-contributing.md ├── lint.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config.json 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - node_modules 5 | notifications: 6 | email: false 7 | node_js: 8 | - '8' 9 | before_script: 10 | - npm prune 11 | - greenkeeper-lockfile-update 12 | after_success: 13 | - npm run semantic-release 14 | branches: 15 | except: 16 | - /^v\d+\.\d+\.\d+$/ 17 | before_install: 18 | - npm install -g npm@5 19 | - npm install -g greenkeeper-lockfile@1 20 | after_script: greenkeeper-lockfile-upload 21 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at richard.littauer@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | Please contribute! Here are some things that would be great: 4 | - Open an issue! 5 | - Open a pull request! 6 | - Say hi! 👋 7 | 8 | Please abide by the [code of conduct](CODE_OF_CONDUCT.md). 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Richard Littauer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build A Space 2 | 3 | [![Build Status](https://travis-ci.org/mntnr/build-a-space.svg?branch=master)](https://travis-ci.org/mntnr/build-a-space) [![Greenkeeper badge](https://badges.greenkeeper.io/mntnr/build-a-space.svg)](https://greenkeeper.io/) 4 | 5 | > Automatically add community documentation to your repository 6 | 7 | ## Background 8 | 9 | I often clean up repositories, and have ideas for what I want to add to them. However, just as often, I manually add files to repositories. [@gr2m](https://github.com/gr2m) got me thinking - wouldn't it be better if I could automatically add files to a repository, using a GitHub bot? 10 | 11 | This GitHub bot does _just that_. It adds a Contributing guide, a Code of Conduct, a README if you don't have one stubbed out, a License, and more to a repository. It lints your `package.json`. It tells you if you don't have an email specified in your CoC. It does a lot of stuff. 12 | 13 | The point is to make building a space for community to grow _easier_. This stuff isn't rocket science, but doing it manually day after day is the hard part. Let's make it easier to build a space. 14 | 15 | ## Install 16 | 17 | You can install this globally with npm: 18 | 19 | ``` 20 | $ npm install -g build-a-space 21 | ``` 22 | 23 | You'll need a [GitHub token](https://github.com/settings/tokens). Put it in the `env.js` file, or in a `$BUILD_A_SPACE` token in your environment. 24 | 25 | You _may_ be able to install this locally and include it, but I have no idea how it would react. 26 | 27 | ## Usage 28 | 29 | ``` 30 | Usage 31 | $ build-a-space [opts] 32 | 33 | Options 34 | -f, --fork Create and use a fork instead of pushing to a branch 35 | -t, --test Don't open issues or create pull requests 36 | -c, --config The path to a configuration file 37 | -b, --branch The default branch to use instead of 'master' 38 | --email The email for the Code of Conduct 39 | --licensee The person to license the repository to 40 | --travis Edit the Travis file 41 | --open Open the PR url afterwards 42 | 43 | Examples 44 | $ build-a-space mntnr/build-a-space 45 | ``` 46 | 47 | Substitute another repo as needed. It drives itself from there. 48 | 49 | ### Configuration 50 | 51 | You can specify a configuration file to stop having to type lots and lots of flags for multiple repositories. This will overwrite any flags you send in. 52 | 53 | ``` 54 | $ build-a-space -c=config.json 55 | ``` 56 | 57 | And, in `config.json`: 58 | 59 | ``` 60 | { 61 | "email": "richard@maintainer.io", 62 | "licensee": "Richard Littauer", 63 | "contributing": "./contributing.md" 64 | } 65 | ``` 66 | 67 | The contributing file path needs to be in the `build-a-space` directory. 68 | 69 | ## Maintainers 70 | 71 | [Me](https://burntfen.com). 72 | 73 | And [you](https://github.com/mntnr/build-a-space/issues/new?title=I%20want%20to%20be%20a%20maintainer!)? 74 | 75 | ## Contribute 76 | 77 | I would love for this to be a community effort. For now, I am hacking away at it because I want to be able to use it quickly get various documents into place as needed for different organizations I work with. However, if would be great if others would start using it, as well. 78 | 79 | Check out the [Contributing guide](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md) for more. 80 | 81 | ## License 82 | 83 | [MIT](LICENSE) © 2017 Richard Littauer 84 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const meow = require('meow') 4 | const fs = require('mz/fs') 5 | const path = require('path') 6 | const fn = require('./src/index') 7 | const gitRemoteOriginUrl = require('git-remote-origin-url') 8 | const gitRemoteUpstreamUrl = require('git-remote-upstream-url') 9 | 10 | const cli = meow({ 11 | 'help': ` 12 | Usage 13 | $ build-a-space [opts] 14 | 15 | Options 16 | -f, --fork Create and use a fork instead of pushing to a branch 17 | -t, --test Don't open issues or create pull requests 18 | -c, --config The path to a configuration file 19 | -b, --branch The default branch to use instead of 'master' 20 | --email The email for the Code of Conduct 21 | --licensee The person to license the repository to 22 | --travis Edit the travis file 23 | --open Open the PR url afterwards 24 | 25 | Examples 26 | $ build-a-space mntnr/build-a-space 27 | `, 28 | 'flags': { 29 | 'help': { 30 | type: 'boolean', 31 | alias: 'h' 32 | }, 33 | 'fork': { 34 | type: 'boolean', 35 | alias: 'f' 36 | }, 37 | 'test': { 38 | type: 'boolean', 39 | alias: 't' 40 | }, 41 | 'config': { 42 | type: 'string', 43 | alias: 'c' 44 | }, 45 | 'branch': { 46 | type: 'string', 47 | alias: 'b' 48 | }, 49 | 'email': { 50 | type: 'string' 51 | }, 52 | 'licensee': { 53 | type: 'string' 54 | }, 55 | 'travis': { 56 | type: 'boolean', 57 | default: false 58 | }, 59 | 'open': { 60 | type: 'boolean', 61 | defaul: false 62 | }, 63 | 'version': { 64 | type: 'boolean', 65 | alias: 'v' 66 | } 67 | } 68 | }) 69 | 70 | // TODO Make this into it's own module, gh-get-shortname 71 | function getRepoFromConfig () { 72 | return gitRemoteUpstreamUrl() 73 | .catch(() => gitRemoteOriginUrl()) 74 | .then(res => res.match(/([^/:]+\/[^/.]+)(\.git)?$/)[1]) 75 | } 76 | 77 | async function getConfig (configPath) { 78 | if (configPath) { 79 | let config = await fs.readFileSync(path.join(__dirname, configPath)) 80 | .toString('utf8') 81 | return JSON.parse(config) 82 | } 83 | } 84 | 85 | async function letsGo () { 86 | console.log('') 87 | const repoName = cli.input[0] || await getRepoFromConfig() 88 | Object.assign(cli.flags, await getConfig(cli.flags.config)) 89 | await fn(repoName, cli.flags) 90 | console.log('') 91 | } 92 | 93 | letsGo() 94 | -------------------------------------------------------------------------------- /env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | BUILD_A_SPACE: process.env.BUILD_A_SPACE, 3 | BASE_URL: process.env.BASE_URL || 'https://api.github.com' 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /fixtures/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | Please contribute! Here are some things that would be great: 4 | - [Open an issue!](https://github.com/[GITHUB REPONAME]/issues/new) 5 | - Open a pull request! 6 | - Say hi! :wave: 7 | 8 | Please abide by the [code of conduct](CODE_OF_CONDUCT.md). 9 | -------------------------------------------------------------------------------- /fixtures/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017 by [INSERT LICENSEE] 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /fixtures/js/.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - node_modules 5 | notifications: 6 | email: false 7 | node_js: 8 | - 'node' 9 | - '8' 10 | - '7' 11 | before_script: 12 | - npm prune 13 | - greenkeeper-lockfile-update 14 | after_success: 15 | - npm run semantic-release 16 | branches: 17 | except: 18 | - /^v\d+\.\d+\.\d+$/ 19 | before_install: 20 | - npm install -g npm@5 21 | - npm install -g greenkeeper-lockfile@1 22 | after_script: greenkeeper-lockfile-upload 23 | -------------------------------------------------------------------------------- /lib/githubHelpers.js: -------------------------------------------------------------------------------- 1 | const atob = require('atob') 2 | const btoa = require('btoa') 3 | const console = require('./robo') 4 | 5 | module.exports = { 6 | // getCurrentSha, 7 | // getFileBlob, 8 | bunchFiles, 9 | sameOnBranch, 10 | commitFile, 11 | checkStatus 12 | } 13 | 14 | async function checkStatus (github, file) { 15 | let {status} = await github.get(`/repos/${github.repoName}/contents/${file.path}?branch=${github.branchName}`) 16 | .catch(err => { 17 | console.robolog(`Unable to find ${file.name}.`) 18 | return err.response 19 | }) 20 | return status 21 | } 22 | 23 | async function sameOnBranch (github, file) { 24 | // Check if the file is any different on the branch 25 | const {data} = await github.get(`/repos/${github.targetRepo}/contents/${file.path}?branch=${github.branchName}`) 26 | .catch(err => { 27 | if (err) { 28 | // console.log('File does not exist, so cannot check sameOnBranch.') 29 | } 30 | return false 31 | }) 32 | 33 | // If the content is the same, don't add a new commit 34 | // Shim them in and out to make sure that there's no formatting differences 35 | return (data) ? btoa(atob(file.content)) === btoa(atob(data.content)) : false 36 | } 37 | 38 | async function commitFile (github, file) { 39 | const commit = { 40 | branch: github.branchName, 41 | content: file.content, 42 | message: file.message, 43 | path: file.path 44 | } 45 | 46 | if (!file.newFile) { 47 | commit.sha = await getCurrentSha(github, file.path) 48 | } 49 | 50 | await github.put(`/repos/${github.targetRepo}/contents/${file.path}?branch=${github.branchName}`, commit) 51 | .catch(err => { 52 | if (err) { 53 | console.robowarn(`Unable to add ${file.name}!`) 54 | } 55 | }) 56 | } 57 | 58 | // This function bunches up multiple file changes into the same commit. 59 | // It depends on a files object: { name, filepath, content, note }. 60 | async function bunchFiles (github, filesToCheck, opts) { 61 | opts = opts || {} 62 | 63 | // Always work off of master, for now. TODO Enable other dev branches 64 | // get the current commit object and current tree 65 | const { 66 | data: {commit: {sha: currentCommitSha}}, 67 | data: {commit: {commit: {tree: {sha: treeSha}}}} 68 | } = await github.get(`/repos/${github.repoName}/branches/${github.defaultBranch}`) 69 | .catch(err => { 70 | if (err) { 71 | console.robowarn('Unable to get commit information to bunch files') 72 | } 73 | }) 74 | 75 | // retrieve the content of the blob object that tree has for that particular file path 76 | const {data: {tree}} = await github.get(`/repos/${github.repoName}/git/trees/${treeSha}`) 77 | .catch(err => { 78 | if (err) { 79 | console.robowarn('Unable to get tree') 80 | } 81 | }) 82 | 83 | // change the content somehow and post a new blob object with that new content, getting a blob SHA back 84 | const newBlobs = await Promise.all(filesToCheck.map(async file => getFileBlob(github, file))) 85 | 86 | if (newBlobs.length !== 0 && !opts.test) { 87 | const newTree = tree.concat(newBlobs) 88 | 89 | // post a new tree object with that file path pointer replaced with your new blob SHA getting a tree SHA back 90 | const {data: {sha: newTreeSha}} = await github.post(`/repos/${github.targetRepo}/git/trees`, { 91 | tree: newTree, 92 | base_tree: treeSha 93 | }) 94 | 95 | // create a new commit object with the current commit SHA as the parent and the new tree SHA, getting a commit SHA back 96 | const {data: {sha: newCommitSha}} = await github.post(`/repos/${github.targetRepo}/git/commits`, { 97 | message: opts.message, 98 | tree: newTreeSha, 99 | parents: [currentCommitSha] 100 | }).catch(err => { 101 | if (err) { 102 | console.log('Unable to create new commit obj') 103 | } 104 | }) 105 | 106 | // update the reference of your branch to point to the new commit SHA 107 | await github.post(`/repos/${github.targetRepo}/git/refs/heads/${github.branchName}`, { 108 | sha: newCommitSha 109 | }).catch(err => { 110 | if (err) { 111 | console.log('Unable to update refs with new commit') 112 | } 113 | }) 114 | } 115 | return filesToCheck 116 | } 117 | 118 | async function getFileBlob (github, file) { 119 | console.robolog(`Adding ${file.name} file`) 120 | 121 | // Create a blob 122 | const {data: blob} = await github.post(`/repos/${github.targetRepo}/git/blobs`, { 123 | content: file.content, 124 | encoding: 'base64' 125 | }).catch(err => { 126 | if (err) {} 127 | console.robofire(`I can't post to a foreign repo! Do you have access?`) 128 | console.log('') 129 | process.exit(1) 130 | }) 131 | 132 | return { 133 | mode: '100644', 134 | type: 'blob', 135 | path: file.filePath, // Puts them all in the base directory for now 136 | sha: blob.sha, 137 | url: blob.url 138 | } 139 | } 140 | 141 | async function getCurrentSha (github, filename) { 142 | const {data: {sha: currentSha}} = await github.get(`/repos/${github.targetRepo}/contents/${filename}?branch=${github.branchName}`) 143 | .catch(err => { 144 | if (err) {} 145 | console.robofire('Unable to get current sha, most likely due to undefined branch.') 146 | }) 147 | return currentSha 148 | } 149 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | function joinNotes (arr) { 2 | return arr.map(file => { 3 | if (file) { 4 | return file.note.map(note => `- [ ] ${note}`).join('\n') 5 | } 6 | }).join('\n') 7 | } 8 | 9 | module.exports = { 10 | joinNotes 11 | } 12 | -------------------------------------------------------------------------------- /lib/lintPackageJson.js: -------------------------------------------------------------------------------- 1 | const deepEqual = require('deep-equal') 2 | 3 | const githubPreviewHeaders = [ 4 | `application/vnd.github.mercy-preview+json`, // Topics https://developer.github.com/v3/repos/#replace-all-topics-for-a-repository 5 | `application/vnd.github.drax-preview+json` // Licenses https://developer.github.com/v3/licenses/ 6 | ] 7 | 8 | // TODO Return to this if relevant 9 | // Set field in package 10 | // Apply note to notes 11 | // function check (field, expected, note) { 12 | // if (!field) { 13 | // field = expected 14 | // push note. 15 | // } 16 | // } 17 | 18 | // Set the package description to match the GitHub description 19 | async function checkDescription (github, pkg, ghDescription, notesForUser) { 20 | if (ghDescription !== pkg.description) { 21 | if (ghDescription && !pkg.description) { 22 | pkg.description = ghDescription 23 | notesForUser.push(`We've added "${ghDescription}" as the description in the \`package.json\`. We got this from the GitHub repo description.`) 24 | } else if (!ghDescription && pkg.description) { 25 | const {data} = await github.patch(`/repos/${github.repoName}`, { 26 | description: pkg.description, 27 | name: github.repoName 28 | }).catch(err => { 29 | if (err) {} 30 | console.robowarn(`Unable to set GitHub description using \`package.json\` description. Probably a permissions error.`) 31 | notesForUser.push(`Add a GitHub description. Your \`package.json\` description should work.`) 32 | }) 33 | if (data.description === pkg.description) { 34 | console.robolog(`I set the GitHub description to match the \`package.json\` description.`) 35 | } 36 | } else { 37 | notesForUser.push(`Check the \`package.json\` description. It didn't match the GitHub description for the repository.`) 38 | } 39 | } 40 | } 41 | 42 | function checkRepository (github, pkg, notesForUser) { 43 | // TODO Enable this, too: JSON.parse({'type': 'git', 'url': `https://github.com/${github.repoName}.git`}).toString() 44 | const expectedRepo = { 45 | 'type': 'git', 46 | // TODO Allow "url": "git://github.com/simonv3/covenant-generator.git" 47 | 'url': `https://github.com/${github.repoName}.git` 48 | } 49 | if (!pkg.repository) { 50 | pkg.repository = expectedRepo // username/repo is also valid https://docs.npmjs.com/files/package.json#repository 51 | } else if (!deepEqual(pkg.repository, expectedRepo)) { 52 | notesForUser.push(`We expected the repository url in the \`package.json\` to be ${expectedRepo.url.split('.git')[0]}, and it wasn't. Is this intentional?`) 53 | } 54 | } 55 | 56 | // Check that the homepage is valid 57 | function checkHomepage (github, pkg, notesForUser) { 58 | // TODO Use the GitHub homepage if it exists 59 | if (!pkg.homepage) { 60 | pkg.homepage = `https://github.com/${github.repoName}` 61 | notesForUser.push(`Check that the homepage in the \`package.json\` is OK. Another one besides your GitHub repo might work. We've set it to https://github.com/${github.repoName}.`) 62 | } 63 | } 64 | 65 | function checkBugs (github, pkg, notesForUser) { 66 | const validBugString = `https://github.com/${github.repoName}/issues` 67 | if (!pkg.bugs || pkg.bugs === validBugString.replace('https', 'http') || pkg.bugs.url === validBugString.replace('https', 'http')) { 68 | pkg.bugs = { 69 | 'url': `https://github.com/${github.repoName}/issues` 70 | } 71 | } else if ((pkg.bugs.url && pkg.bugs.url !== validBugString) || pkg.bugs !== validBugString) { 72 | notesForUser.push(`Check that the bugs field in the package.json is OK. It doesn't match what we'd expect, which would be https://github.com/${github.repoName}/issues`) 73 | } 74 | } 75 | 76 | function arrToQuotedArr (arr) { 77 | return arr.map(k => `"${k}"`).join(', ') 78 | } 79 | 80 | async function checkKeywords (github, pkg, topics, notesForUser) { 81 | // Note: This uses a GitHub preview header, and may break. 82 | // Uniquify and filter out null and undefined 83 | const allKeywords = [ ...new Set(topics.concat(pkg.keywords)) ].filter(x => x) 84 | if (!deepEqual(allKeywords, pkg.keywords)) { 85 | pkg.keywords = allKeywords // Add this even if allKeywords is empty, because the field is worth having 86 | if (allKeywords.length === 0) { 87 | notesForUser.push(`Add some keywords to your package.json. We've added an empty \`keywords\` field for now.`) 88 | } else { 89 | notesForUser.push(`Check the \`package.json\` keywords. We added these from your GitHub topics: ${arrToQuotedArr(pkg.keywords)}.`) 90 | } 91 | } 92 | if (allKeywords.length !== 0 && !deepEqual(allKeywords, topics)) { 93 | const {data} = await github.put(`/repos/${github.repoName}/topics`, { 94 | names: allKeywords 95 | }).catch(err => { 96 | console.robowarn('Unable to set GitHub topics using `package.json` keywords. Probably a permissions error.') 97 | notesForUser.push(`Add these keywords (from your \`package.json\`) as GitHub topics to your repo: ${arrToQuotedArr(allKeywords)}.`) 98 | if (err) { 99 | return false 100 | } 101 | }) 102 | if (data && data.topics === allKeywords) { 103 | console.robolog('I set the GitHub topics to include all `package.json` keywords.') 104 | notesForUser.push(`Check your GitHub topics. I added some from your \`package.json\` keywords.`) 105 | } 106 | } 107 | } 108 | 109 | module.exports = { 110 | lint, 111 | checkHomepage, 112 | checkRepository, 113 | checkKeywords, 114 | checkDescription 115 | } 116 | 117 | async function lint (github, pkg) { 118 | const notesForUser = [] 119 | 120 | // Add in the headers we need for these calls 121 | githubPreviewHeaders.map(header => github.defaults.headers.common.accept.push(header)) 122 | 123 | const {data: {description, topics, license}} = await github.get(`/repos/${github.repoName}`) 124 | 125 | // Check the GitHub and npm descriptions 126 | await checkDescription(github, pkg, description, notesForUser) 127 | 128 | // Check that the keywords match GitHub topics 129 | await checkKeywords(github, pkg, topics, notesForUser) 130 | 131 | // Check that the homepage exists 132 | checkHomepage(github, pkg, notesForUser) 133 | 134 | // Check that `bugs` matches GitHub URL 135 | checkBugs(github, pkg, notesForUser) 136 | 137 | if (!pkg.license) { 138 | pkg.license = 'MIT' 139 | notesForUser.push(`Check the license in your \`package.json\`. We added "MIT" for now.`) 140 | } else if (license && pkg.license !== license.spdx_id) { 141 | // Check that the license matches 142 | notesForUser.push(`Update the license in your \`package.json\`. It did not match what we found on GitHub, and we were unable to resolve this.`) 143 | } 144 | 145 | // Check that the repository matches 146 | checkRepository(github, pkg, notesForUser) 147 | 148 | if (!pkg.contributors) { 149 | pkg.contributors = [pkg.author] 150 | notesForUser.push(`If there are more contributors, add them to the Contributors field in the \`package.json\`.`) 151 | } 152 | 153 | // Tests 154 | if (!pkg.scripts || !pkg.scripts.test || pkg.scripts.test.indexOf('no test specified') !== -1) { 155 | notesForUser.push(`Add some tests! There aren't any currently set. [Use this link to stub out an issue.](https://github.com/${github.repoName}/issues/new?title=Add%20Tests&body=Tests%20are%20useful%20for%20ensuring%20code%20quality.%20No%20tests%20were%20found%20in%20the%20package%20manifest.)`) 156 | // TODO Open an issue suggesting that they add tests 157 | // const query = querystring.stringify({ 158 | // type: 'issue', 159 | // is: 'open', 160 | // repo: github.repoName 161 | // }, 'tests', ':') 162 | 163 | // const testIssueResult = await github.get(`/search/issues?q=${query}`).catch(err => err) 164 | // console.log('Here', testIssueResult) 165 | 166 | // const result = await github.post(`/repos/${github.repoName}/issues`, { 167 | // title: 'Add tests', 168 | // body: `No tests are specified in the npm manifest. Do you have tests for this repo yet?` 169 | // }) 170 | // 171 | // const {data: {html_url: issueUrl}} = result 172 | // console.log(`🤖🙏 issue opened as a reminder to add tests: ${issueUrl}`) 173 | } 174 | 175 | return {pkg, notesForUser} 176 | } 177 | -------------------------------------------------------------------------------- /lib/robo.js: -------------------------------------------------------------------------------- 1 | console.robolog = function (message) { 2 | return console.log('🤖 ' + message) 3 | } 4 | 5 | console.robofire = function (message) { 6 | return console.log('🔥 ' + message) 7 | } 8 | 9 | console.robowarn = function (message) { 10 | return console.log('⚠️ ' + message) 11 | } 12 | 13 | module.exports = console 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build-a-space", 3 | "version": "1.0.1", 4 | "description": "Automatically add community documentation to your repository", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "ava test/*.js" 8 | }, 9 | "bin": "./cli.js", 10 | "keywords": [ 11 | "api", 12 | "automatic", 13 | "bot", 14 | "community", 15 | "github" 16 | ], 17 | "author": "Richard Littauer (https://burntfen.com)", 18 | "license": "MIT", 19 | "dependencies": { 20 | "atob": "^2.1.2", 21 | "axios": "^0.17.0", 22 | "btoa": "^1.1.2", 23 | "deep-equal": "^1.0.1", 24 | "git-remote-origin-url": "^2.0.0", 25 | "git-remote-upstream-url": "^2.0.0", 26 | "json-diff": "^0.5.2", 27 | "mdast-util-heading-range": "^2.1.0", 28 | "meow": "^4.0.0", 29 | "mz": "^2.7.0", 30 | "opn": "^5.1.0", 31 | "querystring": "^0.2.0", 32 | "remark-parse": "^4.0.0", 33 | "standard": "^13.0.1", 34 | "unified": "^6.1.5", 35 | "unist-util-position": "^3.0.0", 36 | "vfile": "^2.2.0" 37 | }, 38 | "devDependencies": { 39 | "ava": "^0.24.0" 40 | }, 41 | "homepage": "https://github.com/mntnr/build-a-space", 42 | "bugs": "https://github.com/mntnr/build-a-space/issues", 43 | "repository": { 44 | "type": "git", 45 | "url": "https://github.com/mntnr/build-a-space.git" 46 | }, 47 | "contributors": [ 48 | "Richard Littauer (https://burntfen.com)" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /src/checkers/community.js: -------------------------------------------------------------------------------- 1 | const btoa = require('btoa') 2 | const fs = require('mz/fs') 3 | const path = require('path') 4 | const {bunchFiles} = require('../../lib/githubHelpers.js') 5 | const getSection = require('../utilities/get-section') 6 | 7 | module.exports = async function wrap (github, opts) { 8 | const files = await community(github, opts) 9 | return bunchFiles(github, files, { 10 | test: opts.test, 11 | message: `docs: adding community docs` 12 | }) 13 | } 14 | 15 | async function addToBranch (github, file, toCheck, opts) { 16 | let email = opts.email || github.user.email || '[INSERT EMAIL ADDRESS]' 17 | let licensee = opts.licensee || '[INSERT LICENSEE]' 18 | 19 | // Check if file exists already in the branch 20 | const {status} = await github.get(`/repos/${github.targetRepo}/contents/${file.filePath}?branch=${github.branchName}`) 21 | .catch(err => err) 22 | if (status !== 200) { 23 | let fileContent 24 | 25 | if (file.name === 'readme') { 26 | fileContent = Buffer.from(`# ${github.repoName.split('/')[1]} 27 | 28 | TODO This needs to be filled out!`) 29 | 30 | console.robowarn('You need to fill out the README manually!') 31 | 32 | file.content = fileContent.toString('base64') 33 | } else { 34 | if (file.name === 'contributing' && opts.contributing) { 35 | fileContent = await fs.readFileSync(path.join(__dirname, '../../', opts.contributing)).toString('utf8') 36 | } else { 37 | const {data} = await github.get(`/repos/${github.repoName}/readme`) 38 | const readme = Buffer.from(data.content, 'base64') 39 | const filePath = path.join(__dirname, '..', '..', 'fixtures', file.filePath) 40 | fileContent = getSection(readme, 'contribute') || await fs.readFileSync(filePath, 'utf8') 41 | } 42 | 43 | // Text Replacements 44 | if (file.name === 'code_of_conduct') { 45 | fileContent = fileContent.replace('[INSERT EMAIL ADDRESS]', email) 46 | file.note.push(`Check the email in the Code of Conduct. We've added in "${email}".`) 47 | } else if (file.name === 'license') { 48 | fileContent = fileContent.replace('[INSERT LICENSEE]', licensee) 49 | file.note.push(`Check the licensee in the License. We've licensed this to "${licensee}".`) 50 | } else if (file.name === 'contributing') { 51 | fileContent = fileContent.replace('[GITHUB REPONAME]', github.repoName) 52 | } 53 | 54 | file.content = btoa(fileContent) 55 | } 56 | 57 | toCheck.push(file) 58 | } 59 | } 60 | 61 | async function community (github, opts) { 62 | // what is the community vitality like? 63 | const {data: community} = await github.get(`/repos/${github.repoName}/community/profile`) 64 | .catch(err => { 65 | console.robofire('Unable to get community profile. Check your headers.') 66 | return err 67 | }) 68 | 69 | const defaultChecks = [ 70 | { 71 | name: 'readme', 72 | filePath: 'README.md', 73 | note: ['Update your README. It is only specced out, and will need more work. I suggest [standard-readme](https://github.com/RichardLitt/standard-readme) for this.'] 74 | }, 75 | { 76 | name: 'license', 77 | // TODO You don't need all caps for License, and it doesn't need to be a markdown file 78 | filePath: 'LICENSE', 79 | note: [`Check that the license name and year is correct. I've added the MIT license, which should suit most purposes.`] 80 | }, 81 | // TODO Parse in the Contributing section from the README 82 | { 83 | name: 'contributing', 84 | filePath: 'CONTRIBUTING.md', 85 | note: ['Update the Contributing guide to include any repository-specific requests, or to point to a global Contributing document.'] 86 | }, 87 | { 88 | name: 'code_of_conduct', 89 | filePath: 'CODE_OF_CONDUCT.md', 90 | note: [] 91 | } 92 | ] 93 | 94 | let toCheck = [] 95 | 96 | await Promise.all(defaultChecks.map(async file => { 97 | if (community && !community.files[file.name]) { 98 | await addToBranch(github, file, toCheck, opts) 99 | } 100 | })) 101 | 102 | return toCheck 103 | } 104 | -------------------------------------------------------------------------------- /src/checkers/javascript.js: -------------------------------------------------------------------------------- 1 | const btoa = require('btoa') 2 | const lintPackageJson = require('../../lib/lintPackageJson.js') 3 | const {checkStatus, sameOnBranch, commitFile} = require('../../lib/githubHelpers.js') 4 | 5 | module.exports = async function addJavascriptFiles (github) { 6 | // Is this a JS repo? 7 | const {data: {language}} = await github.get(`/repos/${github.repoName}`) 8 | if (language !== 'JavaScript') return 9 | 10 | console.robolog('Assuming this is a JavaScript repository, checking...') 11 | 12 | const toCheck = [] 13 | 14 | const packageFile = { 15 | name: 'package.json', 16 | path: 'package.json', 17 | note: [`Check that nothing drastic happened in the \`package.json\`.`] 18 | } 19 | 20 | // Abandon if there is no package.json 21 | const npmStatus = await checkStatus(github, packageFile) 22 | if (npmStatus === 404) { 23 | console.robowarn('There is no package.json. Is this not checked into npm?') 24 | packageFile.note = [`You have no package.json file checked in that I can find. I am assuming this isn't published on npm. Did I miss something?`] 25 | toCheck.push(packageFile) 26 | } else { 27 | const {data: npm} = await github.get(`/repos/${github.repoName}/contents/package.json?branch=${github.branchName}`) 28 | const pkg = JSON.parse(Buffer.from(npm.content, 'base64')) 29 | // Do the heavylifting in the lintPackage file 30 | const {pkg: newPkg, notesForUser} = await lintPackageJson.lint(github, pkg) 31 | packageFile.note = packageFile.note.concat(notesForUser) 32 | packageFile.content = btoa(JSON.stringify(newPkg, null, 2)) 33 | 34 | if (!await sameOnBranch(github, packageFile)) { 35 | await commitFile(github, { 36 | content: packageFile.content, 37 | message: `chore: updated fields in the package.json 38 | 39 | ${packageFile.note.map(note => `- ${note}`).join('\n')} 40 | `, 41 | path: packageFile.path, 42 | name: packageFile.name 43 | }) 44 | 45 | toCheck.push(packageFile) 46 | } 47 | } 48 | 49 | // TODO Open issue to enable greenkeeper 50 | return toCheck 51 | } 52 | -------------------------------------------------------------------------------- /src/checkers/travis.js: -------------------------------------------------------------------------------- 1 | const fs = require('mz/fs') 2 | const path = require('path') 3 | const { checkStatus, sameOnBranch, commitFile } = require('../../lib/githubHelpers.js') 4 | 5 | module.exports = async function checkTravis (github, opts) { 6 | if (!opts.travis) { 7 | console.robolog('Ignoring Travis file') 8 | return [] 9 | } 10 | 11 | const toCheck = [] 12 | 13 | // If travis file exists 14 | const travisFile = { 15 | name: 'travis', 16 | path: '.travis.yml', 17 | note: ['Check if .travis.yml was overwritten or not.'] 18 | } 19 | 20 | // Only commit if there is already a travis file 21 | const travisStatus = await checkStatus(github, travisFile) 22 | if (travisStatus === 404) { 23 | travisFile.note = [`Consider adding Travis. Travis is useful not only for tests, but also for [greenkeeper](https://greenkeeper.io/) and [semantic-release](https://github.com/semantic-release/semantic-release).`] 24 | toCheck.push(travisFile) 25 | } else { 26 | // Get the content of the template 27 | travisFile.content = await fs.readFileSync(path.join(__dirname, `../../fixtures/js/${travisFile.path}`)).toString('base64') 28 | // If it isn't the same on the branch 29 | if (!await sameOnBranch(github, travisFile)) { 30 | // Commit 31 | await commitFile(github, { 32 | name: travisFile.name, 33 | path: travisFile.path, 34 | message: 'ci: adding travis file with Greenkeeper and semantic-release enabled', 35 | content: travisFile.content 36 | }) 37 | 38 | // And return the final object for travis 39 | toCheck.push(travisFile) 40 | } 41 | } 42 | 43 | return toCheck 44 | } 45 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const env = require('../env') 3 | const console = require('../lib/robo') 4 | const querystring = require('querystring') 5 | const checkCommunity = require('./checkers/community') 6 | const checkJavascript = require('./checkers/javascript') 7 | const checkTravis = require('./checkers/travis') 8 | const messages = require('./messages') 9 | const github = axios.create({ 10 | baseURL: env.BASE_URL, 11 | headers: { 12 | common: { 13 | authorization: `token ${env.BUILD_A_SPACE}`, 14 | accept: [ 15 | `application/vnd.github.black-panther-preview+json`, // Community https://developer.github.com/v3/community 16 | `application/vnd.github.scarlet-witch-preview+json` // CoC https://developer.github.com/v3/codes_of_conduct/ 17 | ] 18 | } 19 | } 20 | }) 21 | const opn = require('opn') 22 | 23 | module.exports = async function index (repoName, opts) { 24 | // https://docs.travis-ci.com/user/environment-variables/ 25 | // TODO Set a sensible repo env 26 | github.repoName = repoName || env.TRAVIS_REPO_SLUG 27 | 28 | // Validate that the repository is actually a repo 29 | await github.get(`/repos/${github.repoName}`) 30 | .catch(err => { 31 | if (err) { 32 | console.robofire('That is not a valid GitHub repository!') 33 | console.log('') 34 | process.exit(1) 35 | } 36 | }) 37 | 38 | console.robolog(`Starting process...`) 39 | 40 | // who am I? 41 | const {data: user} = await github.get('/user') 42 | console.robolog(`Authenticated as ${user.login}. Looking if I already created a pull request.`) 43 | github.user = user 44 | 45 | github.defaultBranch = opts.branch || 'master' 46 | 47 | // Start a pull request 48 | const currentSha = await initPR(github, opts) 49 | 50 | // Create a new fork if we need to 51 | if (opts.fork) { 52 | github.targetRepo = await getOrCreateFork(github, opts) 53 | } else { 54 | github.targetRepo = github.repoName 55 | } 56 | 57 | // Create or use an existing branch 58 | await getOrCreateBranch(github, currentSha) 59 | 60 | // Check the community files 61 | const communityFiles = await checkCommunity(github, opts) 62 | // Check the JavaScript files 63 | const jsFiles = await checkJavascript(github, opts) 64 | const travisFile = await checkTravis(github, opts) 65 | 66 | // Create a pullrequest, and combine notes for the enduser 67 | await createPullRequest(github, communityFiles.concat(jsFiles, travisFile), opts) 68 | } 69 | 70 | async function initPR (github, opts) { 71 | // Do I have a pending pull request? 72 | const query = querystring.stringify({ 73 | type: 'pr', 74 | author: github.user.login, 75 | is: 'open', 76 | repo: github.repoName 77 | }, ' ', ':') 78 | 79 | const {data: pullRequestsResult} = await github.get(`/search/issues?q=${query}`) 80 | const pullRequestNumbers = pullRequestsResult.items.map(pr => pr.number) 81 | 82 | // if there are more than a single pull request, then we have a problem, because 83 | // I don’t know which one to update. So I’ll ask you for help :) 84 | if (pullRequestsResult.total_count > 1) { 85 | console.robolog('🤖🆘 I don’t know how to handle more than one pull requests. Creating an issue.') 86 | if (!opts.test) { 87 | const result = await github.post(`/repos/${github.repoName}/issues`, { 88 | title: messages.issue.title, 89 | body: messages.issue.body(pullRequestNumbers) 90 | }) 91 | const {data: {html_url: issueUrl}} = result 92 | console.robolog(`🤖🙏 issue created: ${issueUrl}`) 93 | } else { 94 | console.robolog(`🤖🙏 issue not created, because test.`) 95 | } 96 | process.exit(1) 97 | } 98 | 99 | if (pullRequestsResult.total_count === 1) { 100 | const pullRequest = pullRequestsResult.items[0] 101 | console.robolog(`Existing pull-request found: ${pullRequest.html_url}`) 102 | 103 | const {data} = await github.get(`/repos/${github.repoName}/pulls/${pullRequest.number}`) 104 | 105 | if (data.head.ref.indexOf('docs') === -1) { 106 | console.robofire(`Existing branch doesn't look like it was made by this tool! Abort!`) 107 | console.log() 108 | process.exit(1) 109 | } 110 | 111 | github.branchName = data.head.ref // as branchName 112 | // TODO Enable existing pull request to be fixed and added to 113 | // await updateFixtures({diffs, github, github.repoName, branchName}) 114 | // console.robolog(`pull-request updated: ${pullRequest.html_url}`) 115 | } else { 116 | console.robolog('No existing pull request found') 117 | github.branchName = `docs/${new Date().toISOString().substr(0, 10)}` 118 | } 119 | 120 | console.robolog(`Looking for last commit sha of ${github.repoName}/git/refs/heads/${github.defaultBranch}`) 121 | const {data: {object: {sha}}} = await github.get(`/repos/${github.repoName}/git/refs/heads/${github.defaultBranch}`) 122 | 123 | return sha 124 | } 125 | 126 | async function getOrCreateFork (github, opts) { 127 | // List forks 128 | const repoOnly = github.repoName.split('/')[1] 129 | 130 | const {data: forks} = await github.get(`/repos/${github.repoName}/forks`) 131 | // Filter forks owner - if it matches github.owner, use that fork 132 | const ownFork = forks.filter(fork => fork.owner.login === github.user.login) 133 | // Return if it does exist 134 | if (ownFork.length !== 0) { 135 | console.robolog(`Using existing fork: ${github.user.login}/${repoOnly}.`) 136 | } else { 137 | var error 138 | // Create it if it don't exist ya'll 139 | // This doesn't seem to be working at all 140 | if (opts.test) { 141 | console.robofire(`Refusing to create fork, because tests.`) 142 | } else { 143 | console.log(github.user.login, repoOnly) 144 | await github.post(`/repos/${github.repoName}/forks`) 145 | .catch(err => { 146 | if (err) { 147 | error = true 148 | console.robofire(`Unable to create a new fork for ${github.user.login}!`) 149 | console.log(err) 150 | } 151 | }) 152 | if (!error) { 153 | console.robolog(`Created new fork: ${github.user.login}/${repoOnly}.`) 154 | } 155 | } 156 | } 157 | return `${github.user.login}/${repoOnly}` 158 | } 159 | 160 | async function getOrCreateBranch (github, sha) { 161 | // Gets a 422 sometimes 162 | const branchExists = await github.get(`/repos/${github.targetRepo}/branches/${github.branchName}`) 163 | .catch(err => { 164 | if (err) { 165 | console.robolog(`Creating new branch on ${github.targetRepo}: ${github.branchName} using last sha ${sha.slice(0, 7)}`) 166 | } // do nothing 167 | }) 168 | if (!branchExists) { 169 | await github.post(`/repos/${github.targetRepo}/git/refs`, { 170 | ref: `refs/heads/${github.branchName}`, 171 | sha 172 | }).catch(err => { 173 | if (err) {} 174 | console.robofire('Unable to create a new branch. Do you have access?') 175 | console.log('') 176 | process.exit(1) 177 | }) 178 | } else { 179 | console.robolog(`Using existing branch on ${github.targetRepo}: ${github.branchName} using last sha ${sha.slice(0, 7)}`) 180 | } 181 | } 182 | 183 | async function createPullRequest (github, files, opts) { 184 | if (github.branchName === github.defaultBranch) { 185 | console.robolog(`No changes (you've run this already), or there is some other issue.`) 186 | console.log() 187 | return 188 | } 189 | 190 | // Are there any commits? 191 | const {data: {commit: {sha: oldBranch}}} = await github.get(`/repos/${github.repoName}/branches/${github.defaultBranch}`) 192 | const {data: {commit: {sha: newBranch}}} = await github.get(`/repos/${github.targetRepo}/branches/${github.branchName}`) 193 | if (oldBranch === newBranch) { 194 | console.robofire(`Unable to create PR because there is no content.`) 195 | console.log() 196 | return 197 | } 198 | 199 | console.robolog(`Creating pull request`) 200 | 201 | if (opts.test) { 202 | console.robolog(`Pull request not created, because tests.`) 203 | } else { 204 | const res = await github.post(`/repos/${github.repoName}/pulls`, { 205 | title: messages.pr.title, 206 | // Where changes are implemented. Format: `username:branch`. 207 | head: `${github.targetRepo.split('/')[0]}:${github.branchName}`, 208 | // TODO Use default_branch across tool, not just `master` branch 209 | base: github.defaultBranch, 210 | body: messages.pr.body(files) 211 | }).catch(async err => { 212 | console.robofire(`Unable to create PR inexplicably.`, err) 213 | }) 214 | if (res) { 215 | console.robolog(`Pull request created: ${res.data.html_url}`) 216 | if (opts.open) { 217 | await opn(res.data.html_url, {wait: false}) 218 | } 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/messages.js: -------------------------------------------------------------------------------- 1 | const {joinNotes} = require('../lib/helpers') 2 | 3 | module.exports = { 4 | 'issue': { 5 | 'title': '🤖🆘 Too many PRs', 6 | 'body': function (prNumbers) { 7 | return `Dearest humans, 8 | 9 | I've run into a problem here. I am trying to update the community docs and to get this repo up-to-scratch. I would usually create a new pull request to let you know about it, or update an existing one. But now there more than one: ${prNumbers.map(number => `#${number}`).join(', ')} 10 | 11 | I don’t know how that happened, did I short-circuit again? 12 | 13 | You could really help me by closing all pull requests or by leaving open the one you want me to keep updating. 14 | 15 | Hope you can fix it (and my circuits) soon. 🙏 16 | 17 | Note: This pull request was automatically generated by https://github.com/mntnr/build-a-space. Found an issue? [Let me know!](https://github.com/mntnr/build-a-space/issues) 18 | ` 19 | } 20 | }, 21 | 'pr': { 22 | 'title': 'Add community documentation', 23 | 'body': function (files) { 24 | return `Dearest humans, 25 | 26 | You are missing some important community files. I am adding them here for you! 27 | 28 | Here are some things you should do manually before merging this Pull Request: 29 | 30 | ${joinNotes(files)} 31 | 32 | Note: This pull request was automatically generated by https://github.com/mntnr/build-a-space. Found an issue? [Let me know!](https://github.com/mntnr/build-a-space/issues) 33 | ` 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utilities/find-section.js: -------------------------------------------------------------------------------- 1 | const position = require('unist-util-position') 2 | const range = require('mdast-util-heading-range') 3 | 4 | module.exports = find 5 | 6 | function find (heading, opts, callback) { 7 | if (callback === undefined) { 8 | callback = opts 9 | opts = undefined 10 | } 11 | 12 | return attacher 13 | 14 | function attacher () { 15 | return transformer 16 | } 17 | 18 | function transformer (tree, file) { 19 | var found = false 20 | 21 | /* Search for `heading`, call `onfind` if found. */ 22 | range(tree, heading, onfind) 23 | 24 | /* Call `callback` if not found. */ 25 | if (!found) { 26 | callback() 27 | } 28 | 29 | function onfind (start, nodes) { 30 | /* Include the heading if given `includeHeading`. */ 31 | var heading = (opts || {}).includeHeading === true 32 | var begin = heading ? start : nodes[0] 33 | var initial = position.start(begin).offset 34 | var final = position.end(nodes[nodes.length - 1]).offset 35 | 36 | /* If there’s content, call `callback` with the doc. */ 37 | if (initial !== undefined && final !== undefined) { 38 | found = true 39 | callback(file.toString('utf8').slice(initial, final)) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utilities/get-section.js: -------------------------------------------------------------------------------- 1 | const vfile = require('vfile') 2 | const unified = require('unified') 3 | const parse = require('remark-parse') 4 | const find = require('./find-section') 5 | 6 | module.exports = getSection 7 | 8 | function getSection (buf, title) { 9 | const file = vfile(buf) 10 | let section 11 | 12 | // Use the section from the readme, if there is one. 13 | const processor = unified() 14 | .use(parse) 15 | .use(find(title, {includeHeading: true}, found)) 16 | 17 | // Parse and process the readme 18 | processor.runSync(processor.parse(file), file) 19 | 20 | return section 21 | 22 | function found (doc) { 23 | section = doc 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at test@example.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /test/fixtures/readme-with-contributing.md: -------------------------------------------------------------------------------- 1 | # Some title 2 | 3 | > Some description 4 | 5 | ## Contribute 6 | 7 | Some longer description. 8 | 9 | ## License 10 | 11 | MIT © Richard McRichface 12 | -------------------------------------------------------------------------------- /test/lint.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').test 2 | const lint = require('../lib/lintPackageJson.js') 3 | const axios = require('axios') 4 | const console = require('../lib/robo') 5 | 6 | // Silence all consoles. Brittle, but works. 7 | if (process.env.npm_config_argv.indexOf('--verbose') === -1) { 8 | console.robolog = console.robofire = console.robowarn = () => {} 9 | } 10 | 11 | const github = axios.create({ 12 | baseURL: 'https://api.example.com', 13 | headers: { 14 | common: {} 15 | } 16 | }) 17 | 18 | // checkHomepage 19 | test('homepage will update if it does not exist', async t => { 20 | const notes = [] 21 | const pkg = {} 22 | github.repoName = 'test/test' 23 | await lint.checkHomepage(github, pkg, notes) 24 | t.is(pkg.homepage, 'https://github.com/test/test') 25 | t.is(notes[0], 'Check that the homepage in the `package.json` is OK. Another one besides your GitHub repo might work. We\'ve set it to https://github.com/test/test.') 26 | }) 27 | 28 | test('homepage will not update if it does exist', async t => { 29 | const pkg = {homepage: 'test.com'} 30 | await lint.checkHomepage(github, pkg, []) 31 | t.is(pkg.homepage, 'test.com') 32 | }) 33 | 34 | // checkRepository 35 | test('repository will update if it does not exist', async t => { 36 | const pkg = {} 37 | await lint.checkRepository(github, pkg, []) 38 | t.deepEqual(pkg.repository, { 39 | 'type': 'git', 40 | 'url': `https://github.com/test/test.git` 41 | }) 42 | }) 43 | 44 | test('repository will not update if it does exist', async t => { 45 | const pkg = {repository: true} 46 | await lint.checkRepository(github, pkg, []) 47 | t.is(pkg.repository, true) 48 | }) 49 | 50 | test('repository will add a note it does exist and doesnt match', async t => { 51 | const pkg = {repository: true} 52 | const notes = [] 53 | await lint.checkRepository(github, pkg, notes) 54 | t.is(notes[0], 'We expected the repository url in the `package.json` to be https://github.com/test/test, and it wasn\'t. Is this intentional?') 55 | }) 56 | 57 | test('homepage will not add a note if does exist and is expected', async t => { 58 | const pkg = { 59 | 'repository': { 60 | 'type': 'git', 61 | 'url': `https://github.com/test/test.git` 62 | } 63 | } 64 | const notes = [] 65 | await lint.checkRepository(github, pkg, notes) 66 | t.deepEqual(notes, []) 67 | }) 68 | 69 | // checkKeywords 70 | // TODO Find ways of hitting actual API and testing these 71 | // TODO Finish testing checkKeywords 72 | test('checkKeywords will do nothing if there are no topics', async t => { 73 | const github = null 74 | const pkg = {keywords: []} 75 | const notesForUser = [] 76 | const topics = [] 77 | await lint.checkKeywords(github, pkg, topics, notesForUser) 78 | t.deepEqual(pkg.keywords, []) 79 | t.deepEqual(notesForUser, []) 80 | }) 81 | 82 | test('checkKeywords will create a keywords field if it doesn\'t exist', async t => { 83 | const github = null 84 | const pkg = {} 85 | const notesForUser = [] 86 | const topics = [] 87 | await lint.checkKeywords(github, pkg, topics, notesForUser) 88 | t.deepEqual(pkg.keywords, []) 89 | }) 90 | 91 | test('checkKeywords will return nothing if github is not working', async t => { 92 | github.repoName = 'test' 93 | const pkg = {keywords: ['fail']} 94 | const topics = ['test'] 95 | const notesForUser = [] 96 | const err = await lint.checkKeywords(github, pkg, topics, notesForUser) 97 | t.is(notesForUser.includes('Add these keywords (from your `package.json`) as GitHub topics to your repo: "test", "fail".'), true) 98 | t.is(err, undefined) 99 | }) 100 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava').test 2 | const fs = require('fs') 3 | const path = require('path') 4 | const vfile = require('vfile') 5 | const unified = require('unified') 6 | const parse = require('remark-parse') 7 | const getSection = require('../src/utilities/get-section') 8 | const findSection = require('../src/utilities/find-section') 9 | 10 | test('utilities/find-section', t => { 11 | const buf = fs.readFileSync(path.join(__dirname, 'fixtures', 'readme-with-contributing.md')) 12 | 13 | t.is( 14 | getSection(buf, 'missing'), 15 | undefined, 16 | 'should return `undefined` if a section is missing' 17 | ) 18 | 19 | t.is( 20 | getSection(buf, 'contribute'), 21 | [ 22 | '## Contribute', 23 | '', 24 | 'Some longer description.' 25 | ].join('\n'), 26 | 'should find a section in a buffer' 27 | ) 28 | 29 | t.is( 30 | getSection(buf, 'license'), 31 | [ 32 | '## License', 33 | '', 34 | 'MIT © Richard McRichface' 35 | ].join('\n'), 36 | 'should find other sections' 37 | ) 38 | 39 | t.is( 40 | getSection(String(buf), 'contribute'), 41 | [ 42 | '## Contribute', 43 | '', 44 | 'Some longer description.' 45 | ].join('\n'), 46 | 'should find a section in a string' 47 | ) 48 | }) 49 | 50 | test('utilities/find-section', t => { 51 | const filePath = path.join(__dirname, 'fixtures', 'readme-with-contributing.md') 52 | const buf = fs.readFileSync(filePath) 53 | const file = vfile({path: filePath, contents: buf}) 54 | const tree = unified().use(parse).parse(file) 55 | 56 | t.plan(3) 57 | 58 | findSection('missing', function (section) { 59 | t.is( 60 | section, 61 | undefined, 62 | 'should invoke with `undefined` if a section is missing' 63 | ) 64 | })()(tree, file) 65 | 66 | findSection('contribute', function (section) { 67 | t.is( 68 | section, 69 | 'Some longer description.', 70 | 'should find a section' 71 | ) 72 | })()(tree, file) 73 | 74 | findSection('contribute', {includeHeading: true}, function (section) { 75 | t.is( 76 | section, 77 | [ 78 | '## Contribute', 79 | '', 80 | 'Some longer description.' 81 | ].join('\n'), 82 | 'should include the heading if given `includeHeading: true`' 83 | ) 84 | })()(tree, file) 85 | }) 86 | --------------------------------------------------------------------------------