├── src ├── tests │ ├── server │ │ ├── controller │ │ │ ├── badge.js │ │ │ └── user.js │ │ ├── api │ │ │ └── github.js │ │ ├── documents │ │ │ ├── utils.js │ │ │ └── org.js │ │ └── services │ │ │ ├── org.js │ │ │ └── github.js │ ├── .eslintrc │ ├── acceptance_tests │ │ ├── authorize_claowner.spec.js │ │ ├── zzz_cleanup.spec.js │ │ ├── link_repo1_sign_cla.spec.js │ │ ├── claUtils.js │ │ ├── whitelist_user_on_repo2.spec.js │ │ └── githubUtils.js │ ├── client │ │ └── services │ │ │ └── utils.spec.js │ └── karma.config.js ├── server │ ├── templates │ │ ├── cla.ejs │ │ ├── badge.svg │ │ ├── badge_signed.svg │ │ └── badge_not_signed.svg │ ├── middleware │ │ ├── param.js │ │ ├── cleanup.js │ │ ├── utils.js │ │ └── authenticated.js │ ├── webhooks │ │ └── ping.js │ ├── api │ │ ├── github.js │ │ ├── repo.js │ │ └── org.js │ ├── controller │ │ ├── health.js │ │ ├── badge.js │ │ ├── user.js │ │ └── default.js │ ├── documents │ │ ├── user.js │ │ ├── org.js │ │ ├── utils.js │ │ ├── repo.js │ │ └── cla.js │ ├── services │ │ ├── logger.js │ │ ├── org.js │ │ ├── url.js │ │ └── github.js │ ├── passports │ │ ├── token.js │ │ └── github.js │ └── graphQueries │ │ └── github.js ├── client │ ├── favicon.ico │ ├── assets │ │ ├── robots.txt │ │ ├── images │ │ │ ├── preview.gif │ │ │ ├── background.jpg │ │ │ ├── add_custom_fields.gif │ │ │ ├── nervous_remove │ │ │ │ ├── nervous_eye1-37.svg │ │ │ │ ├── nervous_eye2-39.svg │ │ │ │ ├── nervous_drop-38.svg │ │ │ │ ├── nervous_drop-382.svg │ │ │ │ └── nervous-36.svg │ │ │ ├── browser.svg │ │ │ ├── CLA_logo.svg │ │ │ ├── link_button.svg │ │ │ ├── link_active.svg │ │ │ ├── linked.svg │ │ │ ├── link_inactive.svg │ │ │ ├── feature1.svg │ │ │ ├── error.svg │ │ │ └── feature2.svg │ │ ├── styles │ │ │ ├── _well.scss │ │ │ ├── _list-group.scss │ │ │ ├── _tabs.scss │ │ │ ├── _breadcrumb.scss │ │ │ ├── _link.scss │ │ │ ├── _browser.scss │ │ │ ├── _panel.scss │ │ │ ├── _table.scss │ │ │ ├── _settings.scss │ │ │ ├── _animation.scss │ │ │ ├── _btn.scss │ │ │ ├── _navbar.scss │ │ │ └── _modal.scss │ │ └── js │ │ │ └── CLA_signature_MouseOver_edgeActions.js │ ├── templates │ │ ├── 404.html │ │ ├── feature.html │ │ ├── customField.html │ │ ├── cla.html │ │ └── settings.html │ ├── modals │ │ ├── info.js │ │ ├── link.js │ │ ├── error.js │ │ ├── addScope.js │ │ ├── confirm.js │ │ ├── report.js │ │ ├── templates │ │ │ ├── claView.html │ │ │ ├── info_gist.html │ │ │ ├── info_share_gist.html │ │ │ ├── error_modal.html │ │ │ ├── add_scope.html │ │ │ ├── confirmLink.html │ │ │ ├── badge.html │ │ │ ├── confirmRemove.html │ │ │ ├── linkSuccess.html │ │ │ ├── versionView.html │ │ │ ├── whitelist_info.html │ │ │ ├── upload.html │ │ │ └── report.html │ │ ├── versionView.js │ │ ├── claView.js │ │ ├── badge.js │ │ ├── editLinkedItem.js │ │ └── upload.js │ ├── services │ │ ├── linkItem.js │ │ └── utils.js │ └── app.js └── config.js ├── .bowerrc ├── .dockerignore ├── .github ├── invite-contributors.yml ├── config.yml ├── stale.yml ├── settings.yml └── workflows │ └── build.yml ├── .cfignore ├── manifest.yml ├── .eslintignore ├── cla-assistant.json ├── SAP Corporate Contributor License Agreement (5-26-15).pdf ├── .codeclimate.yml ├── bower.json ├── .env.example ├── .gitignore ├── .cf.sh ├── ISSUE_TEMPLATE.md ├── Dockerfile ├── .travis.yml ├── app.js ├── codecept.json ├── DESCRIPTION.MD ├── .eslintrc.js ├── custom-fields-schema.json ├── Gruntfile.js └── package.json /src/tests/server/controller/badge.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/server/controller/user.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "src/bower" 3 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | Dockerfile 3 | -------------------------------------------------------------------------------- /.github/invite-contributors.yml: -------------------------------------------------------------------------------- 1 | team: contributors -------------------------------------------------------------------------------- /.cfignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | log 4 | log.* 5 | -------------------------------------------------------------------------------- /src/server/templates/cla.ejs: -------------------------------------------------------------------------------- 1 | 2 | Please confirm the CLA 3 | -------------------------------------------------------------------------------- /src/tests/.eslintrc: -------------------------------------------------------------------------------- 1 | globals: 2 | sinon: true 3 | rules: 4 | no-unused-expressions: 0 5 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | applications: 2 | - name: cla-assistant-staging 3 | services: 4 | - cla-staging-env 5 | - my-logs -------------------------------------------------------------------------------- /src/client/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Unity-Technologies/cla-assistant/master/src/client/favicon.ico -------------------------------------------------------------------------------- /src/client/assets/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /api/ 3 | Disallow: /check/ 4 | Disallow: /*?pullRequest=* 5 | -------------------------------------------------------------------------------- /src/client/assets/images/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Unity-Technologies/cla-assistant/master/src/client/assets/images/preview.gif -------------------------------------------------------------------------------- /src/client/assets/images/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Unity-Technologies/cla-assistant/master/src/client/assets/images/background.jpg -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | src/bower/** 3 | src/client/assets/js/** 4 | src/client/app.min.* 5 | src/tests/acceptance_tests/**/* 6 | output/** 7 | -------------------------------------------------------------------------------- /src/client/assets/images/add_custom_fields.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Unity-Technologies/cla-assistant/master/src/client/assets/images/add_custom_fields.gif -------------------------------------------------------------------------------- /cla-assistant.json: -------------------------------------------------------------------------------- 1 | { 2 | "default-cla": [ 3 | { 4 | "name": "SAP individual CLA", 5 | "url": "https://gist.github.com/CLAassistant/bd1ea8ec8aa0357414e8" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /src/server/middleware/param.js: -------------------------------------------------------------------------------- 1 | const merge = require('merge') 2 | 3 | module.exports = (req, _res, next) => { 4 | req.args = merge(req.body, req.query) 5 | next() 6 | } 7 | -------------------------------------------------------------------------------- /SAP Corporate Contributor License Agreement (5-26-15).pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Unity-Technologies/cla-assistant/master/SAP Corporate Contributor License Agreement (5-26-15).pdf -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | Ruby: true 3 | JavaScript: true 4 | PHP: true 5 | Python: true 6 | exclude_paths: 7 | - src/client/assets/js/edge.5.0.1.min.js 8 | - src/tests/**/* 9 | -------------------------------------------------------------------------------- /src/client/assets/styles/_well.scss: -------------------------------------------------------------------------------- 1 | .well { 2 | -moz-box-shadow: none; 3 | -webkit-box-shadow: none; 4 | box-shadow: none; 5 | /*border-radius: 5px;*/ 6 | margin-bottom: 0; 7 | } 8 | -------------------------------------------------------------------------------- /src/client/assets/styles/_list-group.scss: -------------------------------------------------------------------------------- 1 | .list-group { 2 | border: 1px solid $input-border; 3 | z-index: 10; 4 | position: absolute; 5 | width: 100%; 6 | margin-top: 33px; 7 | margin-bottom: 15px; 8 | } 9 | -------------------------------------------------------------------------------- /src/client/assets/styles/_tabs.scss: -------------------------------------------------------------------------------- 1 | .nav-tabs { 2 | > li > a { 3 | border-bottom: 0; 4 | color: $brand-primary-muted; 5 | } 6 | 7 | a:hover { 8 | cursor: pointer; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/client/templates/404.html: -------------------------------------------------------------------------------- 1 |
2 |

404

3 |

File Not Found

4 |

Sorry, an error has occurred. Requested page not found!

5 |
-------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cla-assistant", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "bootstrap-sass-official": "~3.2.0" 6 | }, 7 | "devDependencies": { 8 | "angular-mocks": "1.7.0", 9 | "should": "~3.3.1" 10 | } 11 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | export PORT=5000 2 | export PROTOCOL=http 3 | export HOST=localhost 4 | export MONGODB=mongodb://cla_assistant:cla_assistant@localhost:27017/cla_assistant 5 | export GITHUB_CLIENT= 6 | export GITHUB_SECRET= 7 | 8 | export GITHUB_USER= 9 | export GITHUB_PASS= 10 | -------------------------------------------------------------------------------- /src/client/modals/info.js: -------------------------------------------------------------------------------- 1 | module.controller('InfoCtrl', 2 | function($scope, $modalInstance, $window) { 3 | 4 | $scope.origin = $window.location.origin; 5 | 6 | $scope.cancel = function () { 7 | $modalInstance.dismiss('cancel'); 8 | }; 9 | } 10 | ); 11 | -------------------------------------------------------------------------------- /.github/config.yml: -------------------------------------------------------------------------------- 1 | requestInfoReplyComment: > 2 | We would appreciate it if you could provide us with more info about this issue or pull request. 3 | 4 | requestInfoDefaultTitles: 5 | - update readme.md 6 | - updates 7 | 8 | requestInfoOn: 9 | pullRequest: true 10 | issue: true -------------------------------------------------------------------------------- /src/client/modals/link.js: -------------------------------------------------------------------------------- 1 | module.controller('LinkCtrl', 2 | function($scope, $modalInstance) { 3 | $scope.ok = function () { 4 | $modalInstance.close(); 5 | }; 6 | 7 | $scope.cancel = function () { 8 | $modalInstance.dismiss('cancel'); 9 | }; 10 | } 11 | ); 12 | -------------------------------------------------------------------------------- /src/client/modals/error.js: -------------------------------------------------------------------------------- 1 | module.controller('ErrorCtrl', 2 | function($scope, $modalInstance) { 3 | 4 | $scope.ok = function () { 5 | $modalInstance.close(); 6 | }; 7 | 8 | $scope.cancel = function () { 9 | $modalInstance.dismiss('cancel'); 10 | }; 11 | } 12 | ); 13 | -------------------------------------------------------------------------------- /src/server/webhooks/ping.js: -------------------------------------------------------------------------------- 1 | ////////////////////////////////////////////////////////////////////////////////////////////// 2 | // GitHub Ping Webhook Handler 3 | ////////////////////////////////////////////////////////////////////////////////////////////// 4 | 5 | module.exports = function (req, res) { 6 | res.status(200).send('OK'); 7 | }; -------------------------------------------------------------------------------- /src/server/api/github.js: -------------------------------------------------------------------------------- 1 | // module 2 | const github = require('../services/github') 3 | const merge = require('merge') 4 | 5 | module.exports = { 6 | call: async (req) => { 7 | const res = await github.call(merge(req.args, { token: req.user.token })) 8 | 9 | return { data: res.data, meta: res.headers } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/client/modals/addScope.js: -------------------------------------------------------------------------------- 1 | module.controller('AddScopeCtrl', 2 | function($scope, $modalInstance, $window) { 3 | 4 | $scope.origin = $window.location.origin; 5 | 6 | $scope.cancel = function () { 7 | $modalInstance.dismiss('cancel'); 8 | }; 9 | $scope.ok = function () { 10 | $modalInstance.close(); 11 | }; 12 | } 13 | ); 14 | -------------------------------------------------------------------------------- /src/client/assets/styles/_breadcrumb.scss: -------------------------------------------------------------------------------- 1 | .breadcrumb { 2 | 3 | margin-bottom: 0; 4 | padding: 30px 0 0; 5 | 6 | a { 7 | color: $brand-primary; 8 | font-size: $font-size-h4; 9 | 10 | .octicon { 11 | font-size: 20px; 12 | } 13 | } 14 | 15 | a:hover { 16 | color: $brand-primary-hover; 17 | text-decoration: none; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | daysUntilStale: 90 2 | daysUntilClose: 7 3 | exemptLabels: 4 | - greenkeeper 5 | - bug 6 | - feature 7 | staleLabel: wontfix 8 | markComment: > 9 | This issue has been automatically marked as stale because it has not had 10 | recent activity. It will be closed if no further activity occurs. Thank you 11 | for your contributions. 12 | closeComment: false 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | src/bower 4 | output 5 | venv 6 | .env 7 | report 8 | npm-debug.log 9 | test 10 | coverage 11 | src/client/assets/styles/app.css 12 | src/client/assets/styles/app.css.map 13 | src/client/app.min.js 14 | src/client/app.min.js.map 15 | .sass-cache 16 | .cfenv 17 | *.log 18 | log 19 | log.* 20 | .vscode/* 21 | jsconfig.json 22 | src/tests/acceptance_tests/codecept.conf.js 23 | -------------------------------------------------------------------------------- /src/client/assets/styles/_link.scss: -------------------------------------------------------------------------------- 1 | .link-topic { 2 | font-size: 18px; 3 | } 4 | 5 | .link-topic-1, 6 | .link-topic-2 { 7 | font-size: 18px; 8 | margin-left: -10px; 9 | } 10 | 11 | .link-topic-2 { 12 | margin-top: 40px; 13 | padding-left: 0px; 14 | } 15 | 16 | .link-topic-number { 17 | border: 1px solid; 18 | border-radius: 50%; 19 | display: inline-block; 20 | text-align: center; 21 | width: 21px; 22 | } -------------------------------------------------------------------------------- /src/client/assets/styles/_browser.scss: -------------------------------------------------------------------------------- 1 | .browser { 2 | margin-top: -2px; 3 | 4 | .panel-body { 5 | overflow-x: scroll; 6 | padding: 0; 7 | } 8 | 9 | table.browse { 10 | margin: 0; 11 | width: 100%; 12 | 13 | td { 14 | border-bottom: 1px solid $gray-light; 15 | margin: 0; 16 | padding: 8px 10px; 17 | } 18 | 19 | .select i { 20 | font-size: inherit; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/client/templates/feature.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | {{text}} 5 |
6 |
7 | {{header}} 8 |
9 |
10 | -------------------------------------------------------------------------------- /src/client/modals/confirm.js: -------------------------------------------------------------------------------- 1 | module.controller('ConfirmCtrl', 2 | function($scope, $modalInstance, $window, $timeout, selected) { 3 | $scope.gist = selected && selected.gist ? selected.gist : null; 4 | $scope.item = selected && selected.item ? selected.item : null; 5 | 6 | $scope.ok = function () { 7 | $modalInstance.close(selected.item); 8 | }; 9 | 10 | $scope.cancel = function () { 11 | $modalInstance.dismiss('cancel'); 12 | }; 13 | } 14 | ); 15 | -------------------------------------------------------------------------------- /.cf.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Download cf command line client 4 | wget -O - https://cli.run.pivotal.io/stable\?release\=linux64-binary\&source\=github | tar xvz -C . 5 | 6 | if [ $1 = "cla-assistant-feature" ] 7 | then 8 | ./cf login -a https://api.run.pivotal.io -u $CF2_USER -p $CF2_PASS 9 | else 10 | ./cf login -a https://api.run.pivotal.io -u $CF_USER -p $CF_PASS 11 | fi 12 | 13 | echo $1 14 | 15 | ./cf push $1 -c "node app.js" -s cflinuxfs3 --no-manifest -------------------------------------------------------------------------------- /src/client/modals/report.js: -------------------------------------------------------------------------------- 1 | module.controller('ReportCtrl', function($scope, $modalInstance, $window, item) { 2 | $scope.claItem = item; 3 | $scope.newContributors = {loading: false}; 4 | $scope.history = $scope.gist.history.concat({text: 'All versions'}); 5 | // $scope.gist = null; 6 | $scope.selectedVersion = {}; 7 | $scope.selectedVersion.version = $scope.gist.history[0]; 8 | 9 | $scope.cancel = function () { 10 | $modalInstance.dismiss('cancel'); 11 | }; 12 | }); 13 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | URL to the linked Repo/Org: 9 | 10 | Steps to reproduce the problem: 11 | 12 | 1. 13 | 2. 14 | 3. 15 | 16 | What is the expected result? 17 | 18 | What happens instead? 19 | 20 | 30 | -------------------------------------------------------------------------------- /src/client/assets/styles/_panel.scss: -------------------------------------------------------------------------------- 1 | .panel.inverse { 2 | border: 0; 3 | margin-bottom: 0; 4 | 5 | .panel-heading { 6 | background: $gray-darker; 7 | border-bottom: 0; 8 | color: #fff; 9 | } 10 | 11 | .panel-body { 12 | border-bottom: 10px solid #fff; 13 | } 14 | } 15 | 16 | .panel.comment { 17 | word-break: break-all; 18 | 19 | .panel-heading { 20 | background: $gray-light; 21 | } 22 | 23 | .panel-body { 24 | background: #fff; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/server/controller/health.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | router.get('/health/readiness', (req, res) => { 5 | if (!hasValidHealthCheckHeader(req)) { 6 | return res.status(400).end(); 7 | } 8 | 9 | return res 10 | .status(200) 11 | .send('OK') 12 | .end(); 13 | }); 14 | 15 | function hasValidHealthCheckHeader(req) { 16 | return req.header('x-health-check') === 'check'; 17 | } 18 | 19 | module.exports = router; 20 | -------------------------------------------------------------------------------- /src/client/assets/styles/_table.scss: -------------------------------------------------------------------------------- 1 | table { 2 | background: none; 3 | } 4 | 5 | .table { 6 | > tbody > tr { 7 | /*border-bottom-color: $grey-lighter;*/ 8 | } 9 | > tbody > tr > td { 10 | text-overflow: ellipsis; 11 | border-bottom: solid 1px; 12 | border-bottom-color: $gray-lighter; 13 | padding: 10px 25px; 14 | } 15 | } 16 | 17 | .table-hover { 18 | tr.select:hover { 19 | cursor: pointer; 20 | } 21 | } 22 | 23 | td .action-icon { 24 | /*margin-left: 50%;*/ 25 | } 26 | -------------------------------------------------------------------------------- /src/server/documents/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const UserSchema = mongoose.Schema({ 4 | uuid: Number, 5 | name: String, 6 | requests: [{ 7 | repo: String, 8 | owner: String, 9 | numbers: [Number] 10 | }], 11 | token: String 12 | }) 13 | 14 | UserSchema.index({ 15 | name: 1, 16 | uuid: 1 17 | }, { 18 | unique: true 19 | }) 20 | 21 | const User = mongoose.model('User', UserSchema) 22 | 23 | module.exports = { 24 | User: User 25 | } 26 | -------------------------------------------------------------------------------- /src/client/modals/templates/claView.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/client/assets/images/nervous_remove/nervous_eye1-37.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/server/middleware/cleanup.js: -------------------------------------------------------------------------------- 1 | let removeToken = (obj) => { 2 | try { 3 | obj = obj.toObject() 4 | if (obj.token) { 5 | delete obj.token 6 | } 7 | } catch (e) { 8 | //do nothing 9 | } 10 | 11 | return obj 12 | } 13 | 14 | module.exports = { 15 | cleanObject: (obj) => { 16 | if (Object.prototype.toString.call(obj) === '[object Array]') { 17 | let cleanedObj = [] 18 | obj.forEach(function (el) { 19 | cleanedObj.push(removeToken(el)) 20 | }) 21 | 22 | return cleanedObj 23 | } 24 | 25 | return removeToken(obj) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.7 2 | MAINTAINER GoCD Contributors 3 | 4 | EXPOSE 5000 5 | 6 | COPY . /cla-assistant 7 | WORKDIR /cla-assistant 8 | 9 | RUN \ 10 | apk add --update nodejs su-exec git curl bzip2 patch make g++ && \ 11 | addgroup -S cla-assistant && \ 12 | adduser -S -D -G cla-assistant cla-assistant && \ 13 | chown -R cla-assistant:cla-assistant /cla-assistant && \ 14 | su-exec cla-assistant /bin/sh -c 'cd /cla-assistant && npm install && node_modules/grunt-cli/bin/grunt build && rm -rf /home/cla-assistant/.npm .git' && \ 15 | apk del git curl bzip2 patch make g++ && \ 16 | rm -rf /var/cache/apk/* 17 | 18 | USER cla-assistant 19 | CMD npm start 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - 12 5 | cache: 6 | directories: 7 | - src/bower 8 | notifications: 9 | slack: sap-pi-tools:$SLACK_TOKEN 10 | before_script: 11 | - bower install 12 | after_script: 13 | - grunt uglify && grunt mocha_istanbul && grunt coveralls 14 | - if [[ $TRAVIS_PULL_REQUEST == 'false']]; then npm run snyk-monitor; fi 15 | - if [[ $TRAVIS_PULL_REQUEST == 'false' && $TRAVIS_BRANCH == 'release-green' ]]; then ./.cf.sh cla-assistant-green; fi 16 | - if [[ $TRAVIS_PULL_REQUEST == 'false' && $TRAVIS_BRANCH == 'master' ]]; then ./.cf.sh cla-assistant-staging; fi 17 | - if [[ $TRAVIS_PULL_REQUEST == 'false' && $TRAVIS_BRANCH == 'release' ]]; then ./.cf.sh cla-assistant; fi 18 | -------------------------------------------------------------------------------- /src/client/assets/images/nervous_remove/nervous_eye2-39.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | ////////////////////////////////////////////////////////////////////////////////////////////// 2 | // Initialize server 3 | ////////////////////////////////////////////////////////////////////////////////////////////// 4 | 5 | let app = require('./src/server/app.js') 6 | // let http = require('http') 7 | 8 | // // eslint-disable-next-line no-console 9 | // console.log("App is initialized") 10 | // let server = http.createServer(app) 11 | // // eslint-disable-next-line no-console 12 | // console.log("Server is created") 13 | // const listener = server.listen(config.server.localport, function () { 14 | // // eslint-disable-next-line no-console 15 | // console.log('Listening on port ' + listener.address().port) 16 | // }); -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | name: cla-assistant 3 | description: Contributor License Agreement assistant (CLA assistant) 4 | homepage: https://cla-assistant.io/ 5 | private: false 6 | has_issues: true 7 | has_wiki: false 8 | has_downloads: true 9 | default_branch: master 10 | allow_squash_merge: true 11 | allow_merge_commit: true 12 | allow_rebase_merge: true 13 | 14 | labels: 15 | - name: architecture 16 | color: 5319e7 17 | - name: bug 18 | color: d93f0b 19 | - name: design 20 | color: 009800 21 | - name: feature 22 | color: 84b6eb 23 | - name: greenkeeper 24 | color: 00c775 25 | - name: help wanted 26 | color: 006b75 27 | - name: wontfix 28 | color: eeeeee 29 | -------------------------------------------------------------------------------- /src/client/modals/versionView.js: -------------------------------------------------------------------------------- 1 | module.controller('VersionViewCtrl', function($scope, $rootScope, $modalInstance, cla, $location, noCLA, showCLA, $window) { 2 | $scope.claObj = cla; 3 | $scope.noCLA = noCLA; 4 | $scope.showCLA = showCLA; 5 | $scope.cla = null; 6 | $scope.modalInstance = $modalInstance; 7 | $scope.newCLA = null; 8 | 9 | $scope.openNewCla = function () { 10 | $modalInstance.dismiss('Link opened'); 11 | $window.location.href = '/' + $scope.claObj.owner + '/' + $scope.claObj.repo; 12 | }; 13 | 14 | $scope.openRevision = function () { 15 | $window.open($scope.claObj.gist_url + '/revisions'); 16 | }; 17 | 18 | $scope.cancel = function () { 19 | $modalInstance.dismiss('cancel'); 20 | }; 21 | }); 22 | -------------------------------------------------------------------------------- /src/client/assets/images/browser.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /codecept.json: -------------------------------------------------------------------------------- 1 | { 2 | "tests": "./src/tests/acceptance_tests/*spec.js", 3 | "timeout": 10000, 4 | "output": "./src/tests/acceptance_tests/output", 5 | "helpers": { 6 | "WebDriverIO": { 7 | "url": "/", 8 | "browser": "chrome", 9 | "windowSize": "maximize", 10 | "smartWait": 5000, 11 | "restart": false, 12 | "keepCookies": true, 13 | "desiredCapabilities": { 14 | "chromeOptions": { 15 | "args": [ 16 | "--disable-gpu", 17 | "--window-size=800,600" 18 | ] 19 | } 20 | } 21 | } 22 | }, 23 | "include": {}, 24 | "bootstrap": false, 25 | "debug": true, 26 | "mocha": {}, 27 | "name": "cla-assistant" 28 | } -------------------------------------------------------------------------------- /src/tests/acceptance_tests/authorize_claowner.spec.js: -------------------------------------------------------------------------------- 1 | const github = require('./githubUtils') 2 | 3 | let testUserName = process.env.TEST_USER_NAME 4 | let testUserPass = process.env.TEST_USER_PASS 5 | 6 | Feature('Authorize claowner1') 7 | 8 | 9 | Scenario('Authorize cla assistant for claowner1', (I) => { 10 | github.login(I, testUserName, testUserPass) 11 | I.amOnPage('https://preview.cla-assistant.io/') 12 | I.click('Sign in') 13 | I.seeInCurrentUrl('/login/oauth/authorize') 14 | I.waitForEnabled('button#js-oauth-authorize-btn', 5) 15 | I.click('button#js-oauth-authorize-btn') 16 | I.wait(1) 17 | I.seeInCurrentUrl('/login/oauth/authorize') 18 | I.waitForEnabled('button#js-oauth-authorize-btn', 5) 19 | I.click('button#js-oauth-authorize-btn') 20 | I.seeInCurrentUrl('https://preview.cla-assistant.io/') 21 | }) 22 | -------------------------------------------------------------------------------- /src/client/assets/images/nervous_remove/nervous_drop-38.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/client/assets/images/nervous_remove/nervous_drop-382.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/client/modals/templates/info_gist.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/server/api/github.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, beforeEach, afterEach*/ 2 | 3 | // unit test 4 | const assert = require('assert') 5 | const sinon = require('sinon') 6 | 7 | // api 8 | const github_api = require('../../../server/api/github') 9 | 10 | // module 11 | const github = require('../../../server/services/github') 12 | 13 | describe('github:call', () => { 14 | beforeEach(() => sinon.stub(github, 'call').callsFake(async args => { 15 | assert.deepEqual(args, { obj: 'gists', fun: 'list', token: 'abc' }) 16 | return { data: '', headers: '' } 17 | })) 18 | 19 | afterEach(() => github.call.restore()) 20 | 21 | it('should call github service with user token', async () => { 22 | const req = { user: { id: 1, login: 'login', token: 'abc' }, args: { obj: 'gists', fun: 'list' } } 23 | 24 | await github_api.call(req) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/client/assets/styles/_settings.scss: -------------------------------------------------------------------------------- 1 | .hint { 2 | color: $gray; 3 | } 4 | 5 | .separator { 6 | width: 100%; 7 | height: 1px; 8 | background-color: white; 9 | display: flex; 10 | } 11 | 12 | .settings h4, 13 | .settings .h4 { 14 | color: $text-color; 15 | } 16 | 17 | .settings { 18 | img { 19 | height: 1.5em; 20 | min-height: 15px; 21 | } 22 | 23 | .btn { 24 | margin-top: 5px; 25 | width: 100% 26 | } 27 | 28 | .btn:first-of-type { 29 | margin-top: 0px; 30 | } 31 | } 32 | 33 | 34 | .settings img.org { 35 | height: 1em; 36 | vertical-align: inherit; 37 | } 38 | 39 | .settings { 40 | .action { 41 | padding: 0; 42 | } 43 | 44 | .action-icon { 45 | float: right; 46 | font-size: 18px; 47 | margin-right: 15px; 48 | } 49 | 50 | .action-icon:first-of-type { 51 | margin-right: 0; 52 | } 53 | 54 | .edit-icon { 55 | text-align: right; 56 | padding-right: 30px; 57 | } 58 | } -------------------------------------------------------------------------------- /src/server/documents/org.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const utils = require('./utils') 3 | 4 | const OrgSchema = mongoose.Schema({ 5 | orgId: String, 6 | org: String, 7 | gist: String, 8 | token: String, 9 | excludePattern: String, 10 | sharedGist: Boolean, 11 | minFileChanges: Number, 12 | minCodeChanges: Number, 13 | whiteListPattern: String, 14 | privacyPolicy: String, 15 | updated_at: Date 16 | }) 17 | 18 | OrgSchema.methods.isRepoExcluded = function (repo) { 19 | return utils.checkPattern(this.excludePattern, repo) 20 | } 21 | 22 | OrgSchema.methods.isUserWhitelisted = function (user) { 23 | return utils.checkPatternWildcard(this.whiteListPattern, user) 24 | } 25 | 26 | OrgSchema.index({ 27 | orgId: 1, 28 | }, { 29 | unique: true 30 | }) 31 | 32 | const Org = mongoose.model('Org', OrgSchema) 33 | 34 | module.exports = { 35 | Org: Org 36 | } 37 | -------------------------------------------------------------------------------- /src/server/documents/utils.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | module.exports = { 4 | checkPattern: (patternList, item) => { 5 | if (!patternList || !item || !item.includes) { 6 | return false 7 | } 8 | const patterns = patternList.split(',') 9 | 10 | return patterns.filter(pattern => item.includes(pattern)).length > 0 11 | }, 12 | checkPatternWildcard: (patternList, item) => { 13 | if (!patternList || !item || !item.includes) { 14 | return false 15 | } 16 | const patterns = patternList.split(',') 17 | 18 | return patterns.filter(function (pattern) { 19 | pattern = pattern.trim() 20 | if (pattern.includes('*')) { 21 | const regex = _.escapeRegExp(pattern).split('\\*').join('.*') 22 | 23 | return new RegExp(regex).test(item) 24 | } 25 | 26 | return pattern === item 27 | }).length > 0 28 | } 29 | } -------------------------------------------------------------------------------- /src/server/services/logger.js: -------------------------------------------------------------------------------- 1 | const bunyan = require('bunyan') 2 | const BunyanSlack = require('bunyan-slack') 3 | let log 4 | 5 | const formatter = (record, levelName) => { 6 | return { 7 | text: `[${levelName}] ${record.msg} (source: ${record.src.file} line: ${record.src.line})` 8 | } 9 | } 10 | 11 | log = bunyan.createLogger({ 12 | src: true, 13 | name: config.server.http.host, 14 | streams: [{ 15 | name: 'stdout', 16 | level: process.env.ENV == 'debug' ? 'info' : 'debug', 17 | stream: process.stdout 18 | }] 19 | }); 20 | 21 | try { 22 | log.addStream({ 23 | name: 'slack', 24 | level: 'error', 25 | stream: new BunyanSlack({ 26 | webhook_url: config.server.slack.url, 27 | channel: config.server.slack.channel, 28 | username: 'CLA Assistant', 29 | customFormatter: formatter 30 | }) 31 | }) 32 | } catch (e) { 33 | log.info(e) 34 | } 35 | 36 | module.exports = log -------------------------------------------------------------------------------- /src/tests/server/documents/utils.js: -------------------------------------------------------------------------------- 1 | const utils = require('../../../server/documents/utils'); 2 | const assert = require('assert'); 3 | 4 | describe('documents:utils', () => { 5 | describe('When patterns do NOT include wildcards', () => { 6 | it('should do the exact match', () => { 7 | const patterns = 'Intel, Microsoft'; 8 | assert.equal(utils.checkPatternWildcard(patterns, 'Intel-like-org'), false); 9 | assert.equal(utils.checkPatternWildcard(patterns, 'Intel'), true); 10 | assert.equal(utils.checkPatternWildcard(patterns, 'Microsoft'), true); 11 | }); 12 | }); 13 | 14 | describe('When patterns include wildcards', () => { 15 | it('should do the regex match', () => { 16 | const patterns = 'Intel*, Microsoft*'; 17 | assert.equal(utils.checkPatternWildcard(patterns, 'Intel-like-user-name'), true); 18 | assert.equal(utils.checkPatternWildcard(patterns, 'Microsoft-like-user-name'), true); 19 | }); 20 | }); 21 | }); -------------------------------------------------------------------------------- /src/client/assets/styles/_animation.scss: -------------------------------------------------------------------------------- 1 | .counter1 { 2 | -webkit-animation: counter 1s 1s 1 ; 3 | -moz-animation: counter 1s 1s 1 ; 4 | -o-animation: counter 1s 1s 1 ; 5 | animation: counter 1s 1s 1 ; 6 | } 7 | 8 | .counter2 { 9 | -webkit-animation: counter 1s 3s 1 ; 10 | -moz-animation: counter 1s 3s 1 ; 11 | -o-animation: counter 1s 3s 1 ; 12 | animation: counter 1s 3s 1 ; 13 | } 14 | 15 | .counter3 { 16 | -webkit-animation: counter 1s 5s 1 ; 17 | -moz-animation: counter 1s 5s 1 ; 18 | -o-animation: counter 1s 5s 1 ; 19 | animation: counter 1s 5s 1 ; 20 | } 21 | 22 | @-webkit-keyframes counter { 23 | 0%, 100% { color: $gray; } 24 | 50% { color: $gray-darkest; } 25 | } 26 | @-moz-keyframes counter { 27 | 0%, 100% { color: $gray; } 28 | 50% { color: $gray-darkest; } 29 | } 30 | @-o-keyframes counter { 31 | 0%, 100% { color: $gray; } 32 | 50% { color: $gray-darkest; } 33 | } 34 | @keyframes counter { 35 | 0%, 100% { color: $gray; } 36 | 50% { color: $gray-darkest; } 37 | } -------------------------------------------------------------------------------- /src/client/modals/templates/info_share_gist.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/modals/templates/error_modal.html: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /src/tests/acceptance_tests/zzz_cleanup.spec.js: -------------------------------------------------------------------------------- 1 | const github = require('./githubUtils') 2 | const cla = require('./claUtils') 3 | 4 | let testUserName = process.env.TEST_USER_NAME 5 | let testUserPass = process.env.TEST_USER_PASS 6 | let testContributorName = process.env.TEST_CONTRIBUTOR_NAME 7 | let testContributorPass = process.env.TEST_CONTRIBUTOR_PASS 8 | 9 | 10 | Feature('Cleanup Linked Repos') 11 | 12 | Scenario('cleanup CLA assistant', (I) => { 13 | cla.removeLinkedRepo(I, testUserName, 'repo1') 14 | cla.removeLinkedRepo(I, testUserName, 'repo2') 15 | }) 16 | 17 | Scenario('cleanup GitHub', (I) => { 18 | session('owner', () => { 19 | github.login(I, testUserName, testUserPass) 20 | github.deleteRepo(I, testUserName, 'repo1') 21 | github.deleteRepo(I, testUserName, 'repo2') 22 | github.revokePermissions(I) 23 | }) 24 | 25 | session('contributor', () => { 26 | github.login(I, testContributorName, testContributorPass) 27 | github.deleteRepo(I, testContributorName, 'repo1') 28 | github.deleteRepo(I, testContributorName, 'repo2') 29 | github.revokePermissions(I) 30 | }) 31 | }) -------------------------------------------------------------------------------- /src/server/templates/badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | CLA 16 | CLA 17 | signed 18 | signed 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/server/templates/badge_signed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | CLA 16 | CLA 17 | signed 18 | signed 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/server/documents/repo.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const utils = require('./utils') 3 | // const logger = require('../services/logger') 4 | 5 | const RepoSchema = mongoose.Schema({ 6 | repoId: String, 7 | repo: String, 8 | owner: String, 9 | gist: String, 10 | token: String, 11 | sharedGist: Boolean, 12 | minFileChanges: Number, 13 | minCodeChanges: Number, 14 | whiteListPattern: String, 15 | privacyPolicy: String, 16 | updated_at: Date 17 | }) 18 | 19 | const index = { 20 | repoId: 1, 21 | repo: 1, 22 | owner: 1 23 | } 24 | const indexOptions = { 25 | unique: true 26 | } 27 | 28 | RepoSchema.methods.isUserWhitelisted = function (user) { 29 | return utils.checkPatternWildcard(this.whiteListPattern, user) 30 | } 31 | 32 | const Repo = mongoose.model('Repo', RepoSchema) 33 | 34 | // Repo.collection.dropAllIndexes(function (err, results) { 35 | // if (err) { 36 | // logger.warn('Repo collection dropAllIndexes error: ', err) 37 | // logger.warn('dropAllIndexes results: ', results) 38 | // } 39 | // }) 40 | 41 | Repo.collection.createIndex(index, indexOptions) 42 | 43 | module.exports = { 44 | Repo: Repo 45 | } -------------------------------------------------------------------------------- /src/server/templates/badge_not_signed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | CLA 16 | CLA 17 | not signed yet 18 | not signed yet 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/client/modals/templates/add_scope.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/server/documents/cla.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | // const logger = require('../services/logger') 3 | mongoose.Promise = require('q').Promise 4 | 5 | const CLASchema = mongoose.Schema({ 6 | created_at: Date, 7 | end_at: Date, 8 | custom_fields: String, 9 | gist_url: String, 10 | gist_version: String, 11 | owner: String, 12 | ownerId: String, 13 | repo: String, 14 | repoId: String, 15 | org_cla: Boolean, 16 | user: String, 17 | userId: String, 18 | origin: String, 19 | updated_at: Date 20 | }) 21 | 22 | const index = { 23 | repo: 1, 24 | repoId: 1, 25 | owner: 1, 26 | ownerId: 1, 27 | userId: 1, 28 | gist_url: 1, 29 | gist_version: 1, 30 | org_cla: 1 31 | } 32 | const indexOptions = { 33 | unique: true, 34 | partialFilterExpression: { userId: { $exists: true } }, 35 | background: true, 36 | } 37 | 38 | const CLA = mongoose.model('CLA', CLASchema) 39 | 40 | // CLA.collection.dropAllIndexes(function (err, results) { 41 | // if (err) { 42 | // logger.warn('CLA collection dropAllIndexes error: ', err) 43 | // logger.warn('dropAllIndexes results: ', results) 44 | // } 45 | // }) 46 | CLA.collection.createIndex(index, indexOptions) 47 | 48 | module.exports = { 49 | CLA: CLA 50 | } -------------------------------------------------------------------------------- /src/client/assets/images/CLA_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 14 | 15 | -------------------------------------------------------------------------------- /src/client/modals/templates/confirmLink.html: -------------------------------------------------------------------------------- 1 | 3 | 31 | -------------------------------------------------------------------------------- /src/client/assets/js/CLA_signature_MouseOver_edgeActions.js: -------------------------------------------------------------------------------- 1 | /*********************** 2 | * Adobe Edge Animate Composition Actions 3 | * 4 | * Edit this file with caution, being careful to preserve 5 | * function signatures and comments starting with 'Edge' to maintain the 6 | * ability to interact with these actions from within Adobe Edge Animate 7 | * 8 | ***********************/ 9 | (function($, Edge, compId){ 10 | var Composition = Edge.Composition, Symbol = Edge.Symbol; // aliases for commonly used Edge classes 11 | 12 | //Edge symbol: 'stage' 13 | (function(symbolName) { 14 | 15 | 16 | Symbol.bindTriggerAction(compId, symbolName, "Default Timeline", 1000, function(sym, e) { 17 | // insert code here 18 | // Play the timeline at a label or specific time. For example: 19 | // sym.play(500); or sym.play("myLabel"); 20 | sym.play('mouseover'); 21 | 22 | }); 23 | //Edge binding end 24 | 25 | Symbol.bindTriggerAction(compId, symbolName, "Default Timeline", 639, function(sym, e) { 26 | // insert code here 27 | sym.stop('start'); 28 | 29 | }); 30 | //Edge binding end 31 | 32 | Symbol.bindElementAction(compId, symbolName, "${CLA_screen-32}", "mouseover", function(sym, e) { 33 | sym.play('MoveRay'); 34 | 35 | }); 36 | 37 | })("stage"); 38 | //Edge symbol end:'stage' 39 | 40 | })(window.jQuery || AdobeEdge.$, AdobeEdge, "EDGE-110781156"); -------------------------------------------------------------------------------- /src/client/assets/styles/_btn.scss: -------------------------------------------------------------------------------- 1 | .btn { 2 | /*border-radius: 5px;*/ 3 | } 4 | 5 | .btn { 6 | &.btn-info { 7 | @include button-variant($brand-primary-lightest, $brand-primary, $brand-primary); 8 | /*@color; @background; @border*/ 9 | } 10 | &.btn-success { 11 | @include button-variant($brand-primary-lightest, $green, $green); 12 | /*@color; @background; @border*/ 13 | } 14 | &.btn-cancel { 15 | @include button-variant($gray-darker, $gray, $gray); 16 | /*@color; @background; @border*/ 17 | } 18 | 19 | &.disabled, 20 | &[disabled], 21 | fieldset[disabled] & { 22 | @include opacity(.3); 23 | } 24 | } 25 | 26 | .btn_padding { 27 | padding: 6px 12px; 28 | } 29 | 30 | .btn_tab, 31 | .btn_tab:active, .btn_tab.active, 32 | .btn_tab:focus, .btn_tab.focus, 33 | .btn_tab:hover, .btn_tab.hover 34 | { 35 | z-index: 1; 36 | background-color: $gray-light; 37 | border-bottom-color: $gray-light; 38 | border-top-color: darken($brand-info, 12%); 39 | border-right-color: darken($brand-info, 12%); 40 | border-left-color: darken($brand-info, 12%); 41 | outline: none; 42 | } 43 | 44 | .btn-file { 45 | position: relative; 46 | overflow: hidden; 47 | margin-right: 15px; 48 | } 49 | .btn-file input[type=file] { 50 | position: absolute; 51 | top: 0; 52 | right: 0; 53 | min-width: 100%; 54 | min-height: 100%; 55 | font-size: 100px; 56 | text-align: right; 57 | filter: alpha(opacity=0); 58 | opacity: 0; 59 | outline: none; 60 | background: white; 61 | cursor: inherit; 62 | display: block; 63 | } 64 | -------------------------------------------------------------------------------- /src/tests/acceptance_tests/link_repo1_sign_cla.spec.js: -------------------------------------------------------------------------------- 1 | const github = require('./githubUtils') 2 | const cla = require('./claUtils') 3 | 4 | let testUserName = process.env.TEST_USER_NAME 5 | let testUserPass = process.env.TEST_USER_PASS 6 | let testContributorName = process.env.TEST_CONTRIBUTOR_NAME 7 | let testContributorPass = process.env.TEST_CONTRIBUTOR_PASS 8 | 9 | 10 | Feature('Create and link repo1') 11 | 12 | Scenario('create a repo', (I) => { 13 | // github.login(I, testUserName, testUserPass) 14 | github.createRepo(I, testUserName, 'repo1') 15 | }) 16 | 17 | Scenario('Link repo1', (I) => { 18 | cla.linkRepo(I, testUserName, 'repo1') 19 | }) 20 | 21 | Feature('Pull Request') 22 | 23 | Scenario('create a PR', (I) => { 24 | session('contributor', () => { 25 | github.login(I, testContributorName, testContributorPass) 26 | github.createPR(I, testUserName, 'repo1') 27 | 28 | I.wait(3) 29 | I.waitForElement('a.status-actions', 20) 30 | I.see('Pending') 31 | I.waitForVisible('//h3[contains(@class, "timeline-comment-header-text") and contains(., "CLAassistant")]', 10) 32 | I.see('Thank you for your submission') 33 | 34 | I.click('a.status-actions') 35 | I.seeInCurrentUrl('preview.cla-assistant.io/claowner1/repo1') 36 | I.click('//button[@class="btn btn-info btn-lg"]') 37 | I.waitForEnabled('//button[contains(., "Authorize")]', 5) 38 | I.click('//button[contains(., "Authorize")]') 39 | 40 | I.waitInUrl('github.com', 10) 41 | I.seeInCurrentUrl('github.com/claowner1/repo1') 42 | I.see('All committers have signed the CLA') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/client/modals/templates/badge.html: -------------------------------------------------------------------------------- 1 | 26 | 29 | 30 | 34 | -------------------------------------------------------------------------------- /DESCRIPTION.MD: -------------------------------------------------------------------------------- 1 | ### Tagline 2 | Manages the signing of Contributor License Agreements (CLAs) within GitHub Flow. 3 | 4 | ### Short Description 5 | Streamline your workflow and let CLA assistant handle the legal side of contributions to a repository for you. CLA assistant enables contributors to sign CLAs from within a pull request. 6 | 7 | ### Description 8 | To get started, simply store your CLA as a GitHub Gist file then link it with the repository in CLA assistant. Then sit back and relax while CLA assistant: 9 | 10 | - Comments on each opened pull request to ask the contributor to sign the CLA 11 | - Allows contributors to sign a CLA from within a pull request 12 | - Authenticates the signee with his or her GitHub account 13 | - Updates the status of a pull request when the contributor agrees to the CLA 14 | - Automatically asks users to re-sign the CLA for each new pull request in the event the associated Gist & CLA has changed 15 | 16 |

17 | 18 |

19 | 20 | Repository owners can review a list of users who signed the CLA for each version of it. To get started, visit https://cla-assistant.io. 21 | 22 | ### Contacts 23 | - technical support contact – tools@sap.com 24 | - escalation contact - t.jansen@sap.com 25 | 26 | ### Documentation 27 | - Documentation - https://cla-assistant.io/ 28 | - Terms of service - https://gist.github.com/CLAassistant/3a73e4cd729c9d0a6e30 29 | - Privacy Policy - same 30 | - Support - tools@sap.com 31 | - Status - https://twitter.com/cla_assistant 32 | - Pricing - for free. For all. For everything 33 | 34 | ### Category 35 | Collaborate 36 | -------------------------------------------------------------------------------- /src/client/templates/customField.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
({{description}})
4 | 6 |
7 |
8 | 10 | 13 |
14 |
15 | 19 |
20 |
21 | 22 |
23 | {{prop}} 25 |
26 |
27 |
28 |
-------------------------------------------------------------------------------- /src/client/assets/images/link_button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | 22 | 23 | 25 | 26 | -------------------------------------------------------------------------------- /src/client/services/linkItem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.factory('linkItemService', ['$RPCService', 4 | function ($RPCService) { 5 | function createNewItem(item, options, type) { 6 | if (!type) { 7 | type = options; 8 | options = item; 9 | } 10 | var newItem = { 11 | gist: options.gist.url, 12 | sharedGist: options.sharedGist, 13 | whiteListPattern: options.whiteListPattern, 14 | minFileChanges: options.minFileChanges, 15 | minCodeChanges: options.minCodeChanges, 16 | privacyPolicy: options.privacyPolicy 17 | }; 18 | if (type === 'repo') { 19 | newItem.repoId = item.repoId || item.id; 20 | newItem.repo = item.repo || item.name; 21 | newItem.owner = item.owner.login || item.owner; 22 | } else { 23 | newItem.orgId = item.orgId || item.id; 24 | newItem.org = item.org || item.login; 25 | newItem.excludePattern = options.excludePattern; 26 | } 27 | 28 | return newItem; 29 | } 30 | 31 | return { 32 | createLink: function (item, options) { 33 | var type = item.full_name ? 'repo' : 'org'; 34 | var newItem = createNewItem(item, options, type); 35 | 36 | return $RPCService.call(type, 'create', newItem); 37 | }, 38 | 39 | updateLink: function (item) { 40 | var type = item.repoId ? 'repo' : 'org'; 41 | var newItem = createNewItem(item, type); 42 | 43 | return $RPCService.call(type, 'update', newItem); 44 | } 45 | }; 46 | } 47 | ]); -------------------------------------------------------------------------------- /src/server/controller/badge.js: -------------------------------------------------------------------------------- 1 | const express = require('express'), 2 | ejs = require('ejs'), 3 | fs = require('fs'), 4 | path = require('path'), 5 | crypto = require('crypto') 6 | 7 | //api 8 | const cla = require('./../api/cla') 9 | 10 | const router = express.Router() 11 | 12 | router.all('/pull/badge/:signed', (req, res) => { 13 | const fileName = req.params.signed === 'signed' ? 'badge_signed.svg' : 'badge_not_signed.svg' 14 | const status = req.params.signed === 'signed' ? 'signed' : 'pending' 15 | const tmp = fs.readFileSync(path.join(__dirname, '..', 'templates', fileName), 'utf-8') 16 | const hash = crypto.createHash('md5').update(status, 'utf8').digest('hex') 17 | 18 | if (req.get('If-None-Match') === hash) { 19 | return res.status(304).send() 20 | } 21 | 22 | const svg = ejs.render(tmp) 23 | 24 | res.set('Content-Type', 'image/svg+xml') 25 | res.set('Cache-Control', 'no-cache') 26 | res.set('Etag', hash) 27 | res.send(svg) 28 | }) 29 | 30 | 31 | router.all('/readme/badge/:owner/:repo', async (req, res) => { 32 | req.args = { 33 | owner: req.params.owner, 34 | repo: req.params.repo 35 | } 36 | let count = 0 37 | try { 38 | count = await cla.countCLA(req) 39 | } catch (error) { 40 | count = 0 41 | } 42 | let url = 'https://img.shields.io/badge/CLAs signed-' + count + '-0594c6.svg?' 43 | if (req.query) { 44 | url = req.query.style ? `${url}&style=${req.query.style}` : url 45 | url = req.query.label ? `${url}&label=${req.query.label}` : url 46 | url = req.query.colorB ? `${url}&colorB=${req.query.colorB}` : url 47 | url = req.query.logo ? `${url}&logo=${req.query.logo}` : url 48 | } 49 | res.redirect(url) 50 | }) 51 | 52 | module.exports = router 53 | -------------------------------------------------------------------------------- /src/client/assets/images/link_active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 17 | 18 | 19 | 23 | 24 | 26 | 27 | -------------------------------------------------------------------------------- /src/client/assets/images/linked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 | 20 | 21 | 23 | 24 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/client/modals/templates/confirmRemove.html: -------------------------------------------------------------------------------- 1 | 3 | 35 | -------------------------------------------------------------------------------- /src/client/assets/images/link_inactive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 20 | 21 | 22 | 26 | 27 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/client/modals/claView.js: -------------------------------------------------------------------------------- 1 | module.controller('ClaViewCtrl', function ($scope, $modalInstance, $window, cla, $state, $RPCService, $sce, utils) { 2 | $scope.claObj = cla; 3 | $scope.cla = null; 4 | $scope.modalInstance = $modalInstance; 5 | 6 | $scope.getGistName = function (gistObj) { 7 | return utils.getGistAttribute(gistObj, 'filename'); 8 | }; 9 | 10 | function getCLA() { 11 | return $RPCService.call('cla', 'get', { 12 | repo: $scope.claObj.repo, 13 | owner: $scope.claObj.owner, 14 | gist: { 15 | gist_url: $scope.claObj.gist_url, 16 | gist_version: $scope.claObj.gist_version 17 | } 18 | }, function (err, gist) { 19 | if (!err) { 20 | $scope.claText = gist.value.raw; 21 | } 22 | }); 23 | } 24 | 25 | getCLA().then(function (data) { 26 | $scope.cla = $sce.trustAsHtml(data.value.raw); 27 | $scope.cla.text = data.value.raw; 28 | //console.log($scope.cla.text); 29 | }); 30 | 31 | 32 | $scope.cancel = function () { 33 | $modalInstance.dismiss('cancel'); 34 | }; 35 | 36 | }) 37 | .directive('clacontent', [function () { 38 | return { 39 | link: function (scope, element, attrs) { 40 | scope.$watch(attrs.clacontent, function (content) { 41 | if (content) { 42 | var linkIcons = element[0].getElementsByClassName('octicon octicon-link'); 43 | for (var index in linkIcons) { 44 | if (linkIcons.hasOwnProperty(index)) { 45 | angular.element(linkIcons[index]).removeClass('octicon octicon-link'); 46 | } 47 | } 48 | } 49 | }); 50 | } 51 | }; 52 | }]); 53 | -------------------------------------------------------------------------------- /src/server/controller/user.js: -------------------------------------------------------------------------------- 1 | const passport = require('passport') 2 | const express = require('express') 3 | const utils = require('../middleware/utils') 4 | 5 | const router = express.Router() 6 | let scope 7 | 8 | function checkReturnTo(req, res, next) { 9 | scope = null 10 | req.session.requiredScope = null 11 | if (!req.session) { 12 | req.session = {} 13 | } 14 | 15 | if (req.query.public === 'true') { 16 | scope = config.server.github.user_scope.concat() 17 | req.session.requiredScope = 'public' 18 | } 19 | if (req.query.admin === 'true') { 20 | scope = config.server.github.admin_scope.concat() 21 | req.session.requiredScope = 'admin' 22 | } 23 | if (req.query.org_admin === 'true') { 24 | scope.push('admin:org_hook') 25 | req.session.requiredScope = 'org_admin' 26 | } 27 | 28 | req.session.returnTo = req.query.public === 'true' ? req.session.next || req.headers.referer : '/' 29 | 30 | passport.authenticate('github', { 31 | scope: scope 32 | })(req, res, next) 33 | } 34 | 35 | router.get('/auth/github', checkReturnTo) 36 | 37 | router.get('/auth/github/callback', passport.authenticate('github', { 38 | failureRedirect: '/' 39 | }), 40 | function (req, res) { 41 | if (req.user && req.session.requiredScope != 'public' && utils.couldBeAdmin(req.user.login) && (!req.user.scope || req.user.scope.indexOf('write:repo_hook') < 0)) { 42 | return res.redirect('/auth/github?admin=true') 43 | } 44 | res.redirect(req.session.returnTo || req.headers.referer || '/') 45 | req.session.next = null 46 | }) 47 | 48 | router.get('/logout', 49 | function (req, res, next) { 50 | req.logout() 51 | if (!req.query.noredirect) { 52 | res.redirect('/') 53 | } else { 54 | next() 55 | } 56 | } 57 | ) 58 | 59 | module.exports = router -------------------------------------------------------------------------------- /src/client/modals/templates/linkSuccess.html: -------------------------------------------------------------------------------- 1 | 36 | -------------------------------------------------------------------------------- /src/server/services/org.js: -------------------------------------------------------------------------------- 1 | require('../documents/org'); 2 | const mongoose = require('mongoose'); 3 | const Org = mongoose.model('Org'); 4 | 5 | const selection = function (args) { 6 | const selectArguments = args.orgId ? { orgId: args.orgId } : { org: args.org }; 7 | 8 | return selectArguments; 9 | }; 10 | 11 | class OrgService { 12 | async create(args) { 13 | return Org.create({ 14 | orgId: args.orgId, 15 | org: args.org, 16 | gist: args.gist, 17 | token: args.token, 18 | excludePattern: args.excludePattern, 19 | sharedGist: !!args.sharedGist, 20 | minFileChanges: args.minFileChanges, 21 | minCodeChanges: args.minCodeChanges, 22 | whiteListPattern: args.whiteListPattern, 23 | privacyPolicy: args.privacyPolicy, 24 | updatedAt: new Date() 25 | }) 26 | } 27 | 28 | async get(args) { 29 | return Org.findOne(selection(args)) 30 | } 31 | 32 | async update(args) { 33 | const org = await this.get(args) 34 | org.gist = args.gist 35 | org.token = args.token ? args.token : org.token 36 | org.sharedGist = !!args.sharedGist 37 | org.excludePattern = args.excludePattern 38 | org.minFileChanges = args.minFileChanges 39 | org.minCodeChanges = args.minCodeChanges 40 | org.whiteListPattern = args.whiteListPattern 41 | org.privacyPolicy = args.privacyPolicy 42 | org.updatedAt = new Date() 43 | 44 | return org.save() 45 | } 46 | 47 | async getMultiple(args) { 48 | return Org.find({ orgId: { $in: args.orgId } }) 49 | } 50 | 51 | async getOrgWithSharedGist(gist) { 52 | return Org.find({ gist: gist, sharedGist: true }) 53 | } 54 | 55 | remove(args) { 56 | return Org.findOneAndRemove(selection(args)) 57 | } 58 | } 59 | 60 | const orgService = new OrgService() 61 | module.exports = orgService 62 | -------------------------------------------------------------------------------- /src/server/passports/token.js: -------------------------------------------------------------------------------- 1 | const github = require('../services/github') 2 | const passport = require('passport') 3 | const Strategy = require('passport-accesstoken').Strategy 4 | const merge = require('merge') 5 | const User = require('mongoose').model('User') 6 | 7 | function getGHUser(accessToken) { 8 | const args = { 9 | obj: 'users', 10 | fun: 'getAuthenticated', 11 | token: accessToken 12 | } 13 | 14 | return github.call(args) 15 | } 16 | 17 | async function checkToken(accessToken) { 18 | const args = { 19 | obj: 'oauthAuthorizations', 20 | fun: 'checkAuthorization', 21 | arg: { 22 | access_token: accessToken, 23 | client_id: config.server.github.client 24 | }, 25 | basicAuth: { 26 | user: config.server.github.client, 27 | pass: config.server.github.secret 28 | } 29 | } 30 | const res = await github.call(args) 31 | if (!res.data || (res.data && res.data.scopes && res.data.scopes.indexOf('write:repo_hook') < 0)) { 32 | throw new Error('You have not enough rights to call this API') 33 | } 34 | return res.data 35 | } 36 | 37 | passport.use(new Strategy( 38 | async (token, done) => { 39 | try { 40 | const res = await getGHUser(token) 41 | if (!res || !res.data) { 42 | throw new Error('Could not find GitHub user for given token') 43 | } 44 | const dbUser = await User.findOne({ uuid: res.data.id, name: res.data.login }) 45 | if (!dbUser) { 46 | throw new Error(`Could not find user ${res.data.login} in the database`) 47 | } 48 | const authorization = await checkToken(dbUser.token) 49 | 50 | done(null, merge(res.data, { 51 | token: dbUser.token, 52 | scope: authorization.scopes.toString() 53 | })) 54 | } catch (error) { 55 | done(error) 56 | } 57 | } 58 | )) -------------------------------------------------------------------------------- /src/client/modals/badge.js: -------------------------------------------------------------------------------- 1 | module.controller('BadgeCtrl', function ($scope, $modalInstance, $window, repo) { 2 | $scope.claRepo = repo; 3 | $scope.badgeUrl = $window.location + 'readme/badge/' + repo.owner + '/' + repo.repo; 4 | $scope.linkUrl = $window.location + repo.owner + '/' + repo.repo; 5 | $scope.types = [{ 6 | type: 'HTML', 7 | url: 'CLA assistant' 8 | }, { 9 | type: 'Image URL', 10 | url: $scope.badgeUrl 11 | }, { 12 | type: 'Markdown', 13 | url: '[![CLA assistant](' + $scope.badgeUrl + ')](' + $scope.linkUrl + ')' 14 | }, { 15 | type: 'Textile', 16 | url: '!' + $scope.badgeUrl + '(CLA assistant)!:' + $scope.linkUrl 17 | }, { 18 | type: 'RDOC', 19 | url: '{CLA assistant}[' + $scope.linkUrl + ']' 20 | }]; 21 | 22 | $scope.selectedType = {}; 23 | $scope.selectedType.type = $scope.types[0]; 24 | 25 | $scope.cancel = function () { 26 | $modalInstance.dismiss('cancel'); 27 | }; 28 | }) 29 | // copied from https://github.com/sachinchoolur/ngclipboard/blob/master/src/ngclipboard.js 30 | .directive('ngClipboard', function () { 31 | return { 32 | restrict: 'A', 33 | scope: { 34 | ngClipboardSuccess: '&', 35 | ngClipboardError: '&' 36 | }, 37 | link: function (scope, element) { 38 | var clipboard = new Clipboard(element[0]); 39 | 40 | clipboard.on('success', function (e) { 41 | scope.ngClipboardSuccess({ 42 | e: e 43 | }); 44 | }); 45 | 46 | clipboard.on('error', function (e) { 47 | scope.ngClipboardError({ 48 | e: e 49 | }); 50 | }); 51 | } 52 | }; 53 | }); 54 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "mocha": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "parserOptions": { 10 | "sourceType": "module", 11 | "ecmaVersion": 8 12 | }, 13 | "globals": { 14 | "$": false, 15 | "angular": false, 16 | "chunk": true, 17 | "config": true, 18 | "filters": true, 19 | "ga": false, 20 | "io": false, 21 | "models": true, 22 | "module": true, 23 | "moment": false, 24 | "server": true, 25 | "Papa": true, 26 | "Clipboard": true 27 | }, 28 | "rules": { 29 | "dot-notation": "error", 30 | "func-call-spacing": "error", 31 | "func-name-matching": "error", 32 | "handle-callback-err": "warn", 33 | "no-confusing-arrow": "error", 34 | "no-div-regex": "error", 35 | "no-duplicate-imports": "error", 36 | "no-else-return": "error", 37 | "no-empty-function": "error", 38 | "no-multiple-empty-lines": "error", 39 | "no-native-reassign": "error", 40 | "no-path-concat": "warn", 41 | "no-trailing-spaces": "warn", 42 | "no-undef": "error", 43 | "no-unused-expressions": "warn", 44 | "no-unused-vars": "warn", 45 | "no-use-before-define": [ 46 | "error", 47 | { 48 | "functions": false, 49 | "variables": false 50 | } 51 | ], 52 | "no-useless-concat": "error", 53 | "no-useless-constructor": "error", 54 | "no-useless-rename": "error", 55 | "no-useless-return": "error", 56 | "quotes": [ 57 | 1, 58 | "single" 59 | ], 60 | "valid-typeof": "warn", 61 | "semi-spacing": "warn", 62 | "space-infix-ops": "warn", 63 | "yoda": [ 64 | "error", 65 | "never" 66 | ] 67 | } 68 | }; -------------------------------------------------------------------------------- /src/tests/acceptance_tests/claUtils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | linkRepo: function (I, owner, repo) { 3 | I.amOnPage('https://preview.cla-assistant.io/') 4 | I.waitForVisible('//button[contains(., "Configure CLA")]', 5) 5 | I.waitForInvisible('.loading-indicator', 5) 6 | I.click('//button[contains(., "Configure CLA")]') 7 | 8 | I.see('Choose a repository') 9 | I.click('//*[@id="activated_cla"]/div[3]/div[2]/div[2]/div/div[1]') 10 | I.wait(0.5) 11 | I.see(`${owner}/${repo}`) 12 | I.pressKey('Enter') 13 | 14 | I.see('Choose a CLA') 15 | I.click('//*[@id="activated_cla"]/div[3]/div[3]/div[2]/div/div[1]') 16 | I.wait(0.5) 17 | I.see('SAP individual CLA') 18 | I.pressKey('Enter') 19 | 20 | I.waitForVisible('//button[contains(., "LINK")]', 5) 21 | I.click('//button[contains(., "LINK")]') 22 | I.wait(1) 23 | I.waitForVisible('//button[contains(., "Yes")]', 5) 24 | I.click('//button[contains(., "Yes")]') 25 | I.wait(1) 26 | I.waitForVisible('//button[contains(., "Great")]', 5) 27 | I.click('//button[contains(., "Great")]') 28 | 29 | I.waitForVisible('table.table', 5) 30 | I.see('Linked Repositories') 31 | I.see(`${owner} / ${repo}`) 32 | }, 33 | 34 | removeLinkedRepo: function (I, owner, repo) { 35 | I.amOnPage('https://preview.cla-assistant.io/') 36 | I.waitForVisible('table.table', 5) 37 | I.see('Linked Repositories') 38 | I.waitForInvisible('.loading-indicator', 5) 39 | I.waitForVisible('.fa-trash-o', 5) 40 | I.see(`${owner} / ${repo}`) 41 | 42 | I.moveCursorTo('.fa-trash-o') 43 | I.click('.fa-trash-o') 44 | I.wait(3) 45 | I.see('Unlinking will...') 46 | I.waitForVisible('//button[contains(., "Unlink")]') 47 | I.click('//button[contains(., "Unlink")]') 48 | I.wait(2) 49 | I.see('Linked Repositories') 50 | I.dontSee(`${owner} / ${repo}`) 51 | }, 52 | }; -------------------------------------------------------------------------------- /src/tests/server/documents/org.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const Org = require('../../../server/documents/org').Org 3 | 4 | // test data 5 | const testData = require('../testData').data 6 | 7 | describe('org document', () => { 8 | it('should properly work with legacy organisations without excludePattern', async () => { 9 | const testOrg = testData.org_from_db 10 | const org = new Org(testOrg) 11 | 12 | assert.equal(org.isRepoExcluded('foo'), false) 13 | assert.equal(org.isRepoExcluded('qux'), false) 14 | }) 15 | 16 | it('should properly parse excluded repositories', async () => { 17 | const testOrg = testData.org_from_db_with_excluded_patterns 18 | const org = new Org(testOrg) 19 | 20 | assert.equal(org.isRepoExcluded('foo'), true) 21 | assert.equal(org.isRepoExcluded('qux'), false) 22 | }) 23 | 24 | it('should properly parse empty exclusion pattern', async () => { 25 | const testOrg = testData.org_from_db_with_empty_excluded_patterns 26 | const org = new Org(testOrg) 27 | 28 | assert.equal(org.isRepoExcluded('foo'), false) 29 | assert.equal(org.isRepoExcluded('qux'), false) 30 | }) 31 | 32 | it('should properly parse whitelisted users', async () => { 33 | const testOrg = testData.org_from_db_with_excluded_patterns 34 | testOrg.whiteListPattern = 'login0,*1,*[bot]' 35 | const org = new Org(testOrg) 36 | 37 | assert.equal(org.isUserWhitelisted('login0'), true) 38 | assert.equal(org.isUserWhitelisted('login1'), true) 39 | assert.equal(org.isUserWhitelisted('user[bot]'), true) 40 | assert.equal(org.isUserWhitelisted('login2'), false) 41 | }) 42 | 43 | it('should properly parse empty whitelist pattern', async () => { 44 | const testOrg = testData.org_from_db_with_empty_excluded_patterns 45 | const org = new Org(testOrg) 46 | 47 | assert.equal(org.isUserWhitelisted('login0'), false) 48 | assert.equal(org.isUserWhitelisted('login1'), false) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/tests/acceptance_tests/whitelist_user_on_repo2.spec.js: -------------------------------------------------------------------------------- 1 | const github = require(`./githubUtils`) 2 | const cla = require(`./claUtils`) 3 | 4 | let testUserName = process.env.TEST_USER_NAME 5 | let testUserPass = process.env.TEST_USER_PASS 6 | let testContributorName = process.env.TEST_CONTRIBUTOR_NAME 7 | let testContributorPass = process.env.TEST_CONTRIBUTOR_PASS 8 | 9 | 10 | Feature(`Create and link repo2`) 11 | 12 | Scenario(`create repo2`, (I) => { 13 | github.createRepo(I, testUserName, `repo2`) 14 | }) 15 | 16 | Scenario(`link repo2`, (I) => { 17 | cla.linkRepo(I, testUserName, `repo2`) 18 | }) 19 | 20 | Scenario(`Add whitelisted user for repo2`, (I) => { 21 | I.amOnPage(`https://preview.cla-assistant.io/`) 22 | I.waitForVisible(`//button[contains(., "Configure CLA")]`, 5) 23 | I.waitForInvisible(`.loading-indicator`, 5) 24 | I.waitForVisible(`table.table`, 5) 25 | I.see(`Linked Repositories`) 26 | I.see(`${testUserName} / repo2`) 27 | 28 | I.moveCursorTo(`//tr[contains(., "${testUserName} / repo2")]//i[contains(@class,"fa-ellipsis-h")]`) 29 | I.click(`//tr[contains(., "${testUserName} / repo2")]//i[contains(@class,"fa-ellipsis-h")]`) 30 | I.click(`Edit`) 31 | I.waitForEnabled(`#whiteListPattern`, 2) 32 | I.fillField(`#whiteListPattern`, testContributorName) 33 | I.wait(2) 34 | I.waitForElement(`//button[contains(.,"Save")]`) 35 | I.moveCursorTo(`//button[contains(.,"Save")]`) 36 | I.click(`//button[contains(.,"Save")]`) 37 | I.waitForInvisible(`#whiteListPattern`, 5) 38 | }) 39 | 40 | Feature(`Pull Request`) 41 | 42 | Scenario(`create a PR`, (I) => { 43 | session(`contributor`, () => { 44 | github.login(I, testContributorName, testContributorPass) 45 | github.createPR(I, testUserName, `repo2`) 46 | 47 | I.wait(3) 48 | I.waitForElement(`//button[contains(.,"Show all checks")]`, 20) 49 | I.click(`//button[contains(.,"Show all checks")]`) 50 | I.wait(1) 51 | I.see(`successful check`) 52 | I.see(`All CLA requirements met`) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/client/modals/templates/versionView.html: -------------------------------------------------------------------------------- 1 | 43 | -------------------------------------------------------------------------------- /src/client/modals/templates/whitelist_info.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/client/services/utils.spec.js: -------------------------------------------------------------------------------- 1 | /*jshint expr:true*/ 2 | /*sinon, describe, xit, it, beforeEach, afterEach*/ 3 | var utils, testData; 4 | 5 | describe('Utils service', function () { 6 | beforeEach(angular.mock.module('app')); 7 | beforeEach(angular.mock.module('ngAnimateMock')); 8 | 9 | 10 | beforeEach(angular.mock.inject(function ($injector, _utils_) { 11 | utils = _utils_; 12 | 13 | testData = {}; 14 | testData.gist = { 15 | 'url': 'https://api.github.com/gists/aa5a315d61ae9438b18d', 16 | 'id': 'aa5a315d61ae9438b18d', 17 | 'description': 'description of gist', 18 | 'owner': { 19 | 'login': 'octocat', 20 | 'id': 1 21 | }, 22 | 'user': null, 23 | 'files': { 24 | 'ring.erl': { 25 | 'filename': 'Ring', 26 | 'content': 'contents of gist', 27 | 'updated_at': '2010-04-14T02:15:15Z' 28 | } 29 | }, 30 | 'html_url': 'https://gist.github.com/aa5a315d61ae9438b18d', 31 | 'history': [{ 32 | 'url': 'https://api.github.com/gists/aa5a315d61ae9438b18d/57a7f021a713b1c5a6a199b54cc514735d2d462f', 33 | 'version': '57a7f021a713b1c5a6a199b54cc514735d2d462f', 34 | 'user': { 35 | 'login': 'octocat', 36 | 'id': 1 37 | }, 38 | 'committed_at': '2010-04-14T02:15:15Z' 39 | }] 40 | }; 41 | })); 42 | 43 | it('should return name of the given gist', function () { 44 | var name = utils.getGistAttribute(testData.gist, 'filename'); 45 | 46 | name.should.be.equal('Ring'); 47 | }); 48 | 49 | it('should return file name of the given gist', function () { 50 | testData.gist.files['ring.erl'].filename = undefined; 51 | var name = utils.getGistAttribute(testData.gist, 'fileName'); 52 | 53 | name.should.be.equal('ring.erl'); 54 | }); 55 | 56 | it('should return "updated_at" of the given gist', function () { 57 | var name = utils.getGistAttribute(testData.gist, 'updated_at'); 58 | 59 | name.should.be.equal('2010-04-14T02:15:15Z'); 60 | }); 61 | 62 | it('should not fail if gist obj has no files', function () { 63 | testData.gist.files = undefined; 64 | 65 | var name = utils.getGistAttribute(testData.gist, 'filename'); 66 | 67 | (!name).should.be.equal(true); 68 | }); 69 | }); -------------------------------------------------------------------------------- /src/client/services/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var reservedGistFileNames = ['metadata']; 3 | 4 | module.factory('utils', ['$q', '$RPCService', 5 | function ($q, $RPCService) { 6 | return { 7 | getGistAttribute: function (gist, attribute) { 8 | var attr; 9 | if (gist && gist.files) { 10 | Object.keys(gist.files).some(function (file) { 11 | if (reservedGistFileNames.indexOf(file) < 0) { 12 | attr = file; 13 | 14 | return true; 15 | } 16 | }); 17 | attr = gist.files[attr][attribute] ? gist.files[attr][attribute] : attr; 18 | } 19 | 20 | return attr; 21 | }, 22 | getGistContent: function (repoId, orgId, gist_url, gist_version) { 23 | var deferred = $q.defer(); 24 | var gistContent = {}; 25 | var args = { 26 | repoId: repoId, 27 | orgId: orgId 28 | }; 29 | if (gist_url) { 30 | args.gist = { 31 | gist_url: gist_url, 32 | gist_version: gist_version 33 | }; 34 | } 35 | $RPCService.call('cla', 'get', args, function (err, cla) { 36 | if (!err) { 37 | gistContent.claText = cla.value.raw; 38 | gistContent.updatedAt = cla.value.updatedAt; 39 | if (cla.value.meta) { 40 | try { 41 | var metaString = cla.value.meta.replace(/

|<\/p>|\n|\t/g, ''); 42 | gistContent.customFields = JSON.parse(metaString); 43 | gistContent.customKeys = Object.keys(gistContent.customFields); 44 | gistContent.hasCustomFields = true; 45 | deferred.resolve(gistContent); 46 | } catch (ex) { 47 | deferred.reject(ex); 48 | } 49 | } else { 50 | deferred.resolve(gistContent); 51 | } 52 | } else { 53 | deferred.reject(err); 54 | } 55 | }); 56 | 57 | return deferred.promise; 58 | } 59 | }; 60 | } 61 | ]); -------------------------------------------------------------------------------- /src/client/assets/styles/_navbar.scss: -------------------------------------------------------------------------------- 1 | $navbar-default-bg: $body-bg; 2 | /*$navbar-default-color: $gray-darkest; 3 | $navbar-default-link-color: $gray-darkest; 4 | $navbar-default-link-hover-color: $gray-darker; 5 | $navbar-default-brand-hover-color: $gray; 6 | $navbar-default-link-active-color: $gray;*/ 7 | $navbar-margin-bottom: 0; 8 | $nav-link-padding: 15px 20px 15px 0px; 9 | 10 | $grid-float-breakpoint: 100px; 11 | 12 | $navbar-height: 60px; 13 | $footer-height: $navbar-height; 14 | 15 | .navbar a{ 16 | text-decoration: none; 17 | } 18 | 19 | 20 | .navbar-default { 21 | border-color: white; 22 | 23 | .container-fluid{ 24 | height: $navbar-height; 25 | margin: 0px; 26 | padding-top: 15px; 27 | } 28 | } 29 | 30 | .navbar-default .navbar-text { 31 | color: $gray-darkest; 32 | margin: 0; 33 | } 34 | 35 | .navbar-home-right{ 36 | float: right; 37 | } 38 | 39 | .navbar-home-left{ 40 | float: left; 41 | margin-left: 0px 42 | } 43 | 44 | .navbar-fixed-bottom { 45 | position: absolute; 46 | bottom: 0; 47 | width: 100%; 48 | border: transparent; 49 | color: $navbar-default-color; 50 | } 51 | 52 | .navbar-top-border { 53 | height: 1px; 54 | margin: 0px 15px; 55 | background: $brand-primary-lightest; 56 | } 57 | 58 | .navbar-left:first-child { 59 | margin-left: 0px; 60 | } 61 | .navbar{ 62 | min-height: $navbar-height; 63 | } 64 | 65 | .navbar-center { 66 | position: absolute; 67 | left: 0; 68 | margin-left: auto; 69 | margin-right: auto; 70 | width: 100%; 71 | text-align: center; 72 | z-index:-1 73 | } 74 | 75 | @media(max-width: 767px) { 76 | .navbar-text { 77 | margin: 0px; 78 | } 79 | .navbar-default .container-fluid{ 80 | padding-top: 0px; 81 | } 82 | 83 | .navbar-fixed-top .container-fluid{ 84 | padding: 15px; 85 | } 86 | 87 | 88 | .navbar-text-hide{ 89 | visibility: hidden; 90 | } 91 | 92 | .navbar-left { 93 | text-align: center; 94 | margin: 0px; 95 | } 96 | } 97 | 98 | @media (min-width: 992px) { 99 | .navbar-top-space { 100 | margin-top: 55px; 101 | } 102 | } 103 | 104 | .nav-justified>li, .nav-justified>.nav-tabs.nav-justified { 105 | bottom: -1px; 106 | } 107 | 108 | nav { 109 | margin-top: 10px; 110 | } 111 | 112 | nav .nav-justified>li>a { 113 | color: $gray; 114 | } 115 | 116 | .nav-justified>.active>a, 117 | .nav>li>a:hover, 118 | .nav>li>a:focus { 119 | background-color: transparent; 120 | } 121 | 122 | .nav-justified>.active>a, 123 | .nav>li>a:hover { 124 | color: $gray-darkest; 125 | border-bottom: solid; 126 | } 127 | -------------------------------------------------------------------------------- /src/client/modals/editLinkedItem.js: -------------------------------------------------------------------------------- 1 | module.controller('EditLinkedItemCtrl', function ($scope, $modalInstance, $window, $modal, linkItemService, item, gist, gists) { 2 | $scope.linkedItem = item; 3 | $scope.gists = gists; 4 | $scope.selected = {}; 5 | $scope.errorMsg = []; 6 | 7 | $scope.selected.item = angular.copy(item); 8 | 9 | function initGist() { 10 | $scope.selected.gist = { name: gist.description || gist.fileName, url: gist.html_url } || gists.find(function (g) { 11 | return g.url === item.gist; 12 | }); 13 | // $scope.selected.gist = gists.find(function (g) { 14 | // return g.url === item.gist; 15 | // }) || { name: gist.description || gist.fileName, url: gist.html_url }; 16 | } 17 | 18 | function getGistId(url) { 19 | var regexLastSlash = new RegExp('(.*)/$', 'g'); 20 | var result = regexLastSlash.exec(url); 21 | var newUrl = result ? result[1] : url; 22 | var regexGistId = new RegExp('([a-zA-Z0-9_-]*)$', 'g'); 23 | 24 | return regexGistId.exec(newUrl)[1]; 25 | } 26 | 27 | $scope.clear = function ($event) { 28 | $event.stopPropagation(); 29 | $scope.selected.gist = undefined; 30 | }; 31 | 32 | $scope.gistShareInfo = function () { 33 | $modal.open({ 34 | templateUrl: '/modals/templates/info_share_gist.html', 35 | controller: 'InfoCtrl', 36 | windowClass: 'howto' 37 | }); 38 | }; 39 | 40 | $scope.whitelistInfo = function () { 41 | $modal.open({ 42 | templateUrl: '/modals/templates/whitelist_info.html', 43 | controller: 'InfoCtrl', 44 | windowClass: 'howto' 45 | }); 46 | }; 47 | 48 | $scope.ok = function (itemToSave) { 49 | $scope.selectedGistId = getGistId($scope.selected.gist.url); 50 | $scope.itemsGistId = getGistId(itemToSave.gist); 51 | 52 | if ($scope.selectedGistId !== $scope.itemsGistId) { 53 | itemToSave.gist = $scope.selected.gist; 54 | } else if (!itemToSave.gist.url) { 55 | itemToSave.gist = { url: itemToSave.gist }; 56 | } 57 | linkItemService.updateLink(itemToSave).then(function success(data) { 58 | if (data.value) { 59 | $modalInstance.close(data.value); 60 | } 61 | }, function failure(err) { 62 | $scope.errorMsg.push(err); 63 | }); 64 | }; 65 | 66 | $scope.cancel = function () { 67 | $modalInstance.dismiss('cancel'); 68 | }; 69 | 70 | initGist(); 71 | 72 | }); 73 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI/CDPipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - master 12 | 13 | jobs: 14 | build: 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | node_version: 19 | - 12 20 | os: 21 | - ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: setup gcloud CLI 26 | uses: GoogleCloudPlatform/github-actions/setup-gcloud@master 27 | with: 28 | version: '275.0.0' 29 | service_account_key: ${{ secrets.GCP_base64 }} 30 | # Configure docker to use the gcloud command-line tool as a credential helper 31 | - run: gcloud auth configure-docker 32 | - name: Use Node version 12.6 33 | uses: actions/setup-node@v1 34 | with: 35 | version: ${{ matrix.node_version }} 36 | - name: Npm Install 37 | run: | 38 | npm install 39 | 40 | - name: grunt build and test 41 | run: | 42 | grunt build 43 | grunt test 44 | grunt coverage 45 | - name: Test Coverage 46 | uses: coverallsapp/github-action@master 47 | with: 48 | github-token: ${{ secrets.github_token }} 49 | path-to-lcov: ./output/coverage/lcov.info 50 | - name: build the docker image with committ SHA for staging 51 | if: github.ref == 'refs/heads/master' 52 | run: docker build -t eu.gcr.io/sap-cla-assistant/cla-assistant:${GITHUB_SHA} . 53 | - name: build the docker images with tag name and latest for production 54 | if: startsWith(github.ref, 'refs/tags') 55 | run: docker build -t eu.gcr.io/sap-cla-assistant/cla-assistant:${GITHUB_REF:10} -t eu.gcr.io/sap-cla-assistant/cla-assistant:latest -t sapclaassistant/claassistant:latest -t sapclaassistant/claassistant:${GITHUB_REF:10} . 56 | - name: push the latest and release images to dockerhub only for releases 57 | if: startsWith(github.ref, 'refs/tags') 58 | run: | 59 | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} 60 | docker push sapclaassistant/claassistant 61 | - name: push images to gcp 62 | if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags') 63 | run: docker push eu.gcr.io/sap-cla-assistant/cla-assistant 64 | - name: deploy to staging cloud run service 65 | if: github.ref == 'refs/heads/master' 66 | run: gcloud --quiet --project sap-cla-assistant beta run deploy cla-assistant-staging --platform managed --region europe-west1 --image eu.gcr.io/sap-cla-assistant/cla-assistant:${GITHUB_SHA} 67 | -------------------------------------------------------------------------------- /src/client/modals/templates/upload.html: -------------------------------------------------------------------------------- 1 |

-------------------------------------------------------------------------------- /src/tests/acceptance_tests/githubUtils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | login: function (I, name, password) { 3 | I.amOnPage('https://github.com/login') 4 | I.fillField('login', name) 5 | I.fillField('password', password) 6 | I.click('commit') 7 | I.seeInCurrentUrl('https://github.com') 8 | I.wait(1) 9 | }, 10 | 11 | createRepo: function (I, owner, repo) { 12 | I.amOnPage('https://github.com/new') 13 | I.fillField('repository[name]', repo) 14 | I.checkOption('repository[auto_init]') 15 | I.scrollPageToBottom() 16 | I.waitForEnabled('//*[@id="new_repository"]/div[3]/button', 5) 17 | I.click('Create repository') 18 | I.wait(1) 19 | I.seeInCurrentUrl(`https://github.com/${owner}/${repo}`) 20 | }, 21 | 22 | deleteRepo: function (I, owner, repo) { 23 | I.amOnPage(`https://github.com/${owner}/${repo}`) 24 | I.seeInCurrentUrl(`/${owner}/${repo}`) 25 | I.amOnPage(`https://github.com/${owner}/${repo}/settings`) 26 | I.waitForEnabled('//summary[contains(., "Delete this repository")]', 5) 27 | I.click('//summary[contains(., "Delete this repository")]') 28 | I.waitForVisible('//input[contains(@aria-label, "delete this repository")]', 5) 29 | I.fillField('//input[contains(@aria-label, "delete this repository")]', repo) 30 | I.click('I understand the consequences, delete this repository') 31 | I.seeInCurrentUrl('https://github.com') 32 | }, 33 | 34 | revokePermissions: function (I) { 35 | I.amOnPage('https://github.com/settings/applications') 36 | I.click('Revoke') 37 | I.waitForEnabled('//*[@id="facebox"]/div/div/div/form/button', 5) 38 | I.click('//*[@id="facebox"]/div/div/div/form/button') 39 | I.wait(1) 40 | I.refreshPage() 41 | I.see('No authorized applications') 42 | }, 43 | 44 | createPR: function (I, owner, repo) { 45 | I.amOnPage(`https://github.com/${owner}/${repo}/blob/master/README.md`) 46 | I.waitForEnabled('//button[starts-with(@aria-label, "Edit the file") or starts-with(@aria-label, "Fork this project")]', 5) 47 | I.click('//button[starts-with(@aria-label, "Edit the file") or starts-with(@aria-label, "Fork this project")]') //click ~Fork this project and edit the file 48 | 49 | I.waitForVisible('.CodeMirror-line', 5) 50 | I.wait(1) 51 | I.click('.CodeMirror-line') 52 | I.wait(1) 53 | I.pressKey(['Space', 't', 'e', 's', 't', 'Enter']); 54 | I.waitForEnabled('//button[contains(., "Propose file change")]', 5) 55 | I.click('Propose file change') 56 | I.waitForEnabled('//button[contains(., "Create pull request")]', 5) 57 | I.click('Create pull request') 58 | I.click('//*[@id="new_pull_request"]/div[1]/div/div/div[3]/button') 59 | } 60 | }; -------------------------------------------------------------------------------- /src/client/templates/cla.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |

Error

6 |

There is no CLA to sign for {{params.user}}/{{params.repo}}

7 |

({{noLinkedItemError}})

8 |
9 | 10 |
11 |

{{titleMsg()}}

12 |

Version: {{updatedAt}}

13 |
14 |
15 |
16 | 21 | 22 |

23 | 24 |

25 | 29 | 30 |
31 |

32 | 34 | 35 |

36 | 37 | 38 |
39 |

Thank you {{user.value.login}}

40 |

for signing the CLA!

41 |
42 |

we are now taking you back to GitHub

43 |

44 |
45 |
46 |
47 | -------------------------------------------------------------------------------- /src/server/middleware/utils.js: -------------------------------------------------------------------------------- 1 | const githubService = require('../services/github') 2 | const log = require('../services/logger') 3 | const Joi = require('joi') 4 | 5 | class Utils { 6 | couldBeAdmin(username) { 7 | return config.server.github.admin_users.length === 0 || config.server.github.admin_users.indexOf(username) >= 0 8 | } 9 | 10 | async checkRepoPushPermissionByName(repo, owner, token) { 11 | try { 12 | const res = await githubService.call({ 13 | obj: 'repos', 14 | fun: 'get', 15 | arg: { 16 | repo: repo, 17 | owner: owner 18 | }, 19 | token: token 20 | }) 21 | if (!res.data) { 22 | throw new Error('No data returned') 23 | } 24 | if (!res.data.permissions || !res.data.permissions.push) { 25 | throw new Error('User has no push permission for this repo') 26 | } 27 | return res.data 28 | } catch (error) { 29 | if (error.status === 404) { 30 | log.info('User has no authorization for ' + repo + ' repository.') 31 | log.warn(error.stack) 32 | } 33 | throw error 34 | } 35 | } 36 | 37 | async checkRepoPushPermissionById(repoId, token) { 38 | const res = await githubService.call({ 39 | obj: 'repos', 40 | fun: 'getById', 41 | arg: { 42 | id: repoId 43 | }, 44 | token: token 45 | }) 46 | return res.data.permissions.push 47 | } 48 | 49 | async checkOrgAdminPermission(org, username, token) { 50 | try { 51 | const res = await githubService.call({ 52 | obj: 'orgs', 53 | fun: 'getMembership', 54 | arg: { 55 | org: org, 56 | username: username 57 | }, 58 | token: token 59 | }) 60 | if (!res.data) { 61 | throw new Error('No data returned') 62 | } 63 | if (res.data.role !== 'admin') { 64 | throw new Error('User is not an admin of this org') 65 | } 66 | 67 | return res.data 68 | } catch (error) { 69 | if (error.status === 404) { 70 | log.info('User has no authorization for ' + org + ' repository.') 71 | } 72 | throw error 73 | } 74 | 75 | } 76 | 77 | validateArgs(args, schema, allowUnknown = false, convert = true) { 78 | const joiRes = Joi.validate(args, schema, { abortEarly: false, allowUnknown, convert }) 79 | if (joiRes.error) { 80 | joiRes.error.code = 400 81 | throw joiRes.error 82 | } 83 | } 84 | } 85 | 86 | module.exports = new Utils() -------------------------------------------------------------------------------- /src/server/middleware/authenticated.js: -------------------------------------------------------------------------------- 1 | let passport = require('passport') 2 | let q = require('q') 3 | let utils = require('./utils') 4 | 5 | function authenticateForExternalApi(req, res, next) { 6 | passport.authenticate('token', { session: false }, async function (err, user) { 7 | if (err) { 8 | return next(err) 9 | } 10 | 11 | if (!user) { 12 | res.status(401).json({ message: 'Incorrect token credentials' }) 13 | 14 | return 15 | } 16 | let hasPermission = false 17 | try { 18 | if (req.args.repoId) { 19 | hasPermission = await utils.checkRepoPushPermissionById(req.args.repoId, user.token) 20 | } else if (req.args.org) { 21 | hasPermission = await utils.checkOrgAdminPermission(req.args.org, user.login, user.token) 22 | } 23 | } catch (e) { 24 | const message = e || 'You have no push permission for this org or repo' 25 | return res.status(403).json({ message }) 26 | } 27 | // utils.checkRepoPushPermissionById(req.args.repoId, user.token, function (err, hasPermission) { 28 | if (hasPermission) { 29 | req.user = user 30 | next() 31 | } else { 32 | res.status(403).json({ message: err || 'You have no push permission for this org or repo' }) 33 | } 34 | // }) 35 | })(req, res) 36 | } 37 | 38 | function authenticateForAdminOnlyApi(req, res, next) { 39 | passport.authenticate('token', { session: false }, function (err, user) { 40 | if (err) { 41 | return next(err) 42 | } 43 | if (!user) { 44 | return res.status(401).json({ message: 'Incorrect token credentials' }) 45 | } 46 | if (!utils.couldBeAdmin(user.login) || (req.args.org && user.scope.indexOf('admin:org_hook') < 0)) { 47 | return res.status(403).json({ message: 'Must have admin:org_hook permission scope' }) 48 | } 49 | let promises = [] 50 | if (req.args.owner && req.args.repo) { 51 | promises.push(utils.checkRepoPushPermissionByName(req.args.repo, req.args.owner, user.token)) 52 | } 53 | if (req.args.org) { 54 | promises.push(utils.checkOrgAdminPermission(req.args.org, user.login, user.token)) 55 | } 56 | 57 | return q.all(promises).then(function () { 58 | req.user = user 59 | next() 60 | }).catch(function (error) { 61 | return res.status(403).json({ message: error.message || error }) 62 | }) 63 | 64 | })(req, res) 65 | } 66 | 67 | module.exports = function (req, res, next) { 68 | if (config.server.api_access.free.indexOf(req.originalUrl) > -1 || req.isAuthenticated()) { 69 | return next() 70 | } else if (config.server.api_access.external.indexOf(req.originalUrl) > -1) { 71 | return authenticateForExternalApi(req, res, next) 72 | } else if (config.server.api_access.admin_only.indexOf(req.originalUrl) > -1) { 73 | return authenticateForAdminOnlyApi(req, res, next) 74 | } 75 | res.status(401).send('Authentication required') 76 | } 77 | -------------------------------------------------------------------------------- /src/server/graphQueries/github.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | getPRCommitters: (owner, repo, number, cursor) => { 4 | number = typeof number === 'string' ? parseInt(number) : number 5 | let query = ` 6 | query($owner:String! $name:String! $number:Int! $cursor:String!){ 7 | repository(owner: $owner, name: $name) { 8 | pullRequest(number: $number) { 9 | commits(first: 100, after: $cursor) { 10 | totalCount 11 | edges { 12 | node { 13 | commit { 14 | author { 15 | email 16 | name 17 | user { 18 | id 19 | databaseId 20 | login 21 | } 22 | } 23 | committer { 24 | name 25 | user { 26 | id 27 | databaseId 28 | login 29 | } 30 | } 31 | } 32 | } 33 | cursor 34 | } 35 | pageInfo { 36 | endCursor 37 | hasNextPage 38 | } 39 | } 40 | } 41 | } 42 | }`.replace(/ /g, '') 43 | let variables = { 44 | owner: owner, 45 | name: repo, 46 | number: number, 47 | cursor: cursor 48 | } 49 | 50 | return JSON.stringify({ query, variables }) 51 | }, 52 | 53 | getUserOrgs: (owner, cursor) => { 54 | let query = `query ($owner: String! ${cursor ? '$cursor: String!)' : ')'} {` 55 | query = query + ` 56 | user(login: $owner) { 57 | organizations(first: 100${cursor ? ', after: $cursor' : ''}) { 58 | edges { 59 | cursor 60 | node { 61 | login 62 | name 63 | id 64 | databaseId 65 | avatarUrl 66 | viewerCanAdminister 67 | } 68 | } 69 | pageInfo { 70 | endCursor 71 | hasNextPage 72 | } 73 | } 74 | } 75 | }` 76 | let variables = { owner } 77 | if (cursor) { 78 | variables.cursor = cursor 79 | } 80 | 81 | return JSON.stringify({ query, variables }).replace(/ /g, '') 82 | } 83 | } -------------------------------------------------------------------------------- /src/client/assets/styles/_modal.scss: -------------------------------------------------------------------------------- 1 | .modal.fade .modal-dialog { 2 | -webkit-transform: scale(0.5); 3 | -moz-transform: scale(0.5); 4 | -ms-transform: scale(0.5); 5 | transform: scale(0.5); 6 | opacity: 0; 7 | -webkit-transition: all 0.3s linear; 8 | -moz-transition: all 0.3s linear; 9 | transition: all 0.3s linear; 10 | } 11 | 12 | .modal.fade.in .modal-dialog { 13 | -webkit-transform: scale(1); 14 | -moz-transform: scale(1); 15 | -ms-transform: scale(1); 16 | transform: scale(1); 17 | opacity: 1; 18 | -webkit-transition: all 0.3s linear; 19 | -moz-transition: all 0.3s linear; 20 | transition: all 0.3s linear; 21 | } 22 | 23 | .modal-header { 24 | padding: 15px 35px; 25 | } 26 | 27 | .modal-body { 28 | padding: 15px 15px; 29 | } 30 | 31 | .modal-primary { 32 | background-color: $gray-lighter; 33 | color: $gray-darkest; 34 | } 35 | 36 | .modal-primary-2nd { 37 | background-color: $brand-primary-2nd; 38 | color: $gray-lighter; 39 | } 40 | 41 | .modal-title { 42 | text-align: center; 43 | font-size: 26px; 44 | margin-top: 20px; 45 | } 46 | 47 | .modal-dialog { 48 | .icon { 49 | width: 300px; 50 | border: none; 51 | } 52 | 53 | p { 54 | font-size: 16px; 55 | } 56 | 57 | ul > li { 58 | padding-top: 15px; 59 | } 60 | 61 | button { 62 | margin-left: 10px; 63 | font-size: 16px; 64 | } 65 | 66 | .btn-cancel { 67 | font-weight: 200; 68 | } 69 | } 70 | 71 | .confirm-add .modal-dialog { 72 | width: 550px; 73 | 74 | p { 75 | color: $green; 76 | } 77 | } 78 | 79 | .link-success .modal-dialog { 80 | width: 455px; 81 | 82 | button { 83 | margin-left: 0px; 84 | } 85 | } 86 | 87 | .howto .modal-body .free-space { 88 | padding: 35px 15px 0px 15px; 89 | } 90 | 91 | .howto .modal-body .free-space:last-child { 92 | padding-bottom: 20px; 93 | } 94 | 95 | .howto .modal-body .btn, 96 | .validatePr .modal-body .btn { 97 | margin: 20px 10px 0px 0px; 98 | } 99 | 100 | .validatePr .modal-body .btn-info { 101 | height: 45px; 102 | width: 100%; 103 | } 104 | 105 | .edit-linked-item .modal-body.container { 106 | width: initial; 107 | } 108 | 109 | .edit-linked-item .modal-body { 110 | .side-note { 111 | font-size: 12px; 112 | } 113 | 114 | .center-text { 115 | margin-bottom: 10px; 116 | text-align: center; 117 | } 118 | 119 | .wildcard { 120 | vertical-align: text-top; 121 | font-size: medium; 122 | } 123 | } 124 | 125 | .report .modal-body, 126 | .get-badge .modal-body, 127 | .upload .modal-body, 128 | .edit-linked-item .modal-body, 129 | .validatePr .modal-body { 130 | padding: 15px 35px; 131 | 132 | .close-button { 133 | margin-right: -15px; 134 | } 135 | 136 | .export-button { 137 | margin: 30px 0px; 138 | } 139 | 140 | .form-group { 141 | margin-bottom: 10px; 142 | padding-left: 0px; 143 | } 144 | } 145 | 146 | .report .modal-body, 147 | .get-badge .modal-body { 148 | button { 149 | margin: 0; 150 | } 151 | 152 | .selectize-input { 153 | height: 100%; 154 | } 155 | } 156 | 157 | .validatePr .title { 158 | text-align: center; 159 | font-size: 26px; 160 | margin: 20px; 161 | } 162 | -------------------------------------------------------------------------------- /custom-fields-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "JSON Schema for CLA assistant custom fields", 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "type": "object", 5 | "patternProperties": { 6 | "^[A-Za-z0-9]*$": { 7 | "type": "object", 8 | "description": "Each property describes additional data which should be collected from a CLA signer and generates a form field", 9 | "properties": { 10 | "title": { 11 | "type": "string", 12 | "description": "Title is used to create a label for the form field" 13 | }, 14 | "type": { 15 | "oneOf": [{ 16 | "enum": ["hidden", "string", "textarea", "number", "boolean"], 17 | "description": "Field of type boolean generates a checkbox" 18 | }, 19 | { 20 | "type": "object", 21 | "description": "Field of type enum generates radio buttons", 22 | "properties": { 23 | "enum": { 24 | "type": "array", 25 | "minItems": 2, 26 | "items": { 27 | "type": "string" 28 | }, 29 | "uniqueItems": true 30 | } 31 | } 32 | } 33 | ] 34 | }, 35 | "required": { 36 | "type": "boolean", 37 | "description": "Required fields are marked with asterisk and must be filled by signer" 38 | }, 39 | "description": { 40 | "type": "string", 41 | "description": "Field description generates small text behind the label" 42 | }, 43 | "maximum": { 44 | "type": "number", 45 | "description": "Can be used for fields of type number" 46 | }, 47 | "minimum": { 48 | "type": "number", 49 | "description": "Can be used for fields of type number" 50 | }, 51 | "githubKey": { 52 | "type": "string", 53 | "description": "Data of github user profile can be used to prefill the form. This refers to the response of the github api https://developer.github.com/v3/users/#get-a-single-user" 54 | } 55 | }, 56 | "anyOf": [{ 57 | "properties": { 58 | "type": { 59 | "enum": ["hidden"] 60 | }, 61 | "required": { 62 | "enum": [false] 63 | } 64 | } 65 | }, 66 | { 67 | "properties": { 68 | "type": { 69 | "enum": ["string", "textarea", "number", "boolean"] 70 | } 71 | } 72 | }, 73 | { 74 | "properties": { 75 | "type": { 76 | "type": "object", 77 | "properties": { 78 | "enum": { 79 | "type": "array" 80 | } 81 | } 82 | 83 | } 84 | } 85 | } 86 | ], 87 | "additionalProperties": false 88 | } 89 | }, 90 | "dependencies": { 91 | "type": { 92 | "properties": { 93 | "required": { 94 | "enum": [false] 95 | } 96 | } 97 | } 98 | }, 99 | "additionalProperties": false 100 | } -------------------------------------------------------------------------------- /src/server/controller/default.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const path = require('path') 3 | const cla = require('./../api/cla') 4 | const logger = require('./../services/logger') 5 | ////////////////////////////////////////////////////////////////////////////////////////////// 6 | // Default router 7 | ////////////////////////////////////////////////////////////////////////////////////////////// 8 | 9 | const router = express.Router() 10 | 11 | // router.use('/accept', function(req, res) { 12 | router.use('/accept/:owner/:repo', async (req, res) => { 13 | res.set({ 'Cache-Control': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0' }) 14 | 15 | req.args = { 16 | owner: req.params.owner, 17 | repo: req.params.repo 18 | } 19 | 20 | if (req.isAuthenticated()) { 21 | try { 22 | await cla.sign(req) 23 | } catch (e) { 24 | if (e && (!e.code || e.code != 200)) { 25 | logger.error(e) 26 | 27 | return 28 | } 29 | } 30 | 31 | let redirectUrl = `/${req.args.owner}/${req.args.repo}?redirect=true` 32 | redirectUrl = req.query.pullRequest ? `${redirectUrl}&pullRequest=${req.query.pullRequest}` : redirectUrl 33 | res.redirect(redirectUrl) 34 | } else { 35 | req.session.next = req.originalUrl 36 | return res.redirect('/auth/github?public=true') 37 | } 38 | }) 39 | 40 | router.use('/signin/:owner/:repo', (req, res) => { 41 | let redirectUrl = `/${req.params.owner}/${req.params.repo}` 42 | req.session.next = req.query.pullRequest ? `${redirectUrl}?pullRequest=${req.query.pullRequest}` : redirectUrl 43 | 44 | return res.redirect('/auth/github?public=true') 45 | }) 46 | 47 | router.all('/static/*', (req, res) => { 48 | let filePath 49 | if (req.user && req.path === '/static/cla-assistant.json') { 50 | filePath = path.join(__dirname, '..', '..', '..', 'cla-assistant.json') 51 | } else { 52 | filePath = config.server.templates.login 53 | } 54 | res.setHeader('Last-Modified', (new Date()).toUTCString()) 55 | res.status(200).sendFile(filePath) 56 | }) 57 | 58 | router.get('/check/:owner/:repo', (req, res) => { 59 | let referer = req.header('Referer') 60 | let back = referer && referer.includes('github.com') ? referer : 'https://github.com' 61 | logger.info('Recheck PR requested for ', `https://github.com/${req.params.owner}/${req.params.repo}/pull/${req.query.pullRequest}`, `referer was ${referer}`) 62 | cla.validatePullRequest({ 63 | owner: req.params.owner, 64 | repo: req.params.repo, 65 | number: req.query.pullRequest 66 | }) 67 | res.redirect(back) 68 | }) 69 | 70 | router.all('/*', (req, res) => { 71 | let filePath 72 | if (req.path === '/robots.txt') { 73 | filePath = path.join(__dirname, '..', '..', 'client', 'assets', 'robots.txt') 74 | } 75 | else if ((req.user && req.user.scope && req.user.scope.indexOf('write:repo_hook') > -1) || req.path !== '/') { 76 | filePath = path.join(__dirname, '..', '..', 'client', 'home.html') 77 | } else { 78 | filePath = config.server.templates.login 79 | } 80 | res.setHeader('Last-Modified', (new Date()).toUTCString()) 81 | res.status(200).sendFile(filePath) 82 | }) 83 | 84 | module.exports = router -------------------------------------------------------------------------------- /src/server/services/url.js: -------------------------------------------------------------------------------- 1 | const url = require('url') 2 | 3 | module.exports = function () { 4 | let baseUrl = url.format({ 5 | protocol: config.server.http.protocol, 6 | hostname: config.server.http.host, 7 | port: config.server.http.port 8 | }); 9 | 10 | let githubBase = url.format({ 11 | protocol: config.server.github.protocol, 12 | host: config.server.github.host 13 | }); 14 | 15 | let githubApiBase = url.format({ 16 | protocol: config.server.github.protocol, 17 | host: config.server.github.api 18 | }); 19 | 20 | return { 21 | // socket: localSocket, 22 | baseUrl: baseUrl, 23 | baseWebhook: url.resolve(baseUrl, '/github/webhook/'), 24 | claURL: (user, repo, number) => { 25 | let claUrl = url.resolve(baseUrl, '/' + user + '/' + repo) 26 | claUrl = number ? claUrl + '?pullRequest=' + number : claUrl 27 | 28 | return claUrl 29 | }, 30 | githubBase: githubBase, 31 | githubApiBase: githubApiBase, 32 | githubCallback: url.resolve(baseUrl, '/auth/github/callback'), 33 | githubAuthorization: url.resolve(githubBase, '/login/oauth/authorize'), 34 | githubToken: url.resolve(githubBase, '/login/oauth/access_token'), 35 | githubProfile: () => url.resolve(githubApiBase, config.server.github.enterprise ? '/api/v3/user' : '/user'), 36 | githubCommits: (owner, repo, sha, since) => { 37 | let _url = url.resolve(githubApiBase, `/repos/${owner}/${repo}/commits`) 38 | _url = sha ? `${_url}?sha=${sha}` : _url 39 | _url += sha && since ? '&' : since ? '?' : '' 40 | _url = since ? `${_url}since=${since}` : _url 41 | 42 | return _url 43 | }, 44 | githubFileReference: (user, repo, fileReference) => url.resolve(githubBase, `/${user}/${repo}/blob/${fileReference}`), 45 | githubOrgWebhook: (org) => url.resolve(githubApiBase, `/orgs/${org}/hooks`), 46 | githubPullRequests: (owner, repo, state) => { 47 | let _url = `${module.exports.githubRepository(owner, repo)}/pulls` 48 | _url = state ? `${_url}?state=${state}` : _url 49 | 50 | return _url 51 | }, 52 | githubPullRequest: (owner, repo, number) => url.resolve(githubApiBase, '/repos/' + owner + '/' + repo + '/pulls/' + number), 53 | githubPullRequestCommits: (owner, repo, number) => { 54 | return module.exports.githubPullRequest(owner, repo, number) + '/commits' 55 | }, 56 | githubPullRequestComments: (owner, repo, number) => url.resolve(githubApiBase, `/repos/${owner}/${repo}/issues/${number}/comments`), 57 | githubRepository: (owner, repo) => { 58 | let _url = url.resolve(githubApiBase, `/repos/${owner}/${repo}`) 59 | 60 | return _url 61 | }, 62 | pullRequestBadge: (signed) => { 63 | let signed_str = signed ? 'signed' : 'not_signed' 64 | return url.resolve(baseUrl, `/pull/badge/${signed_str}`) 65 | }, 66 | recheckPrUrl: (owner, repo, number) => { 67 | let checkUrl = url.resolve(baseUrl, `/check/${owner}/${repo}`) 68 | checkUrl = number ? `${checkUrl}?pullRequest=${number}` : checkUrl 69 | 70 | return checkUrl 71 | }, 72 | webhook: (repo) => url.resolve(baseUrl, `/github/webhook/${repo}`) 73 | } 74 | }() -------------------------------------------------------------------------------- /src/client/app.js: -------------------------------------------------------------------------------- 1 | var module = angular.module('app', ['cla.filters', 2 | 'ui.utils', 3 | 'ui.router', 4 | 'ui.bootstrap', 5 | 'ui.bootstrap.popover', 6 | 'ui.select', 7 | 'ngSanitize', 8 | 'ngAnimate', 9 | 'ngCsv' 10 | ]); 11 | // eslint-disable-next-line no-unused-vars 12 | var filters = angular.module('cla.filters', []); 13 | 14 | // ************************************************************* 15 | // Delay start 16 | // ************************************************************* 17 | 18 | angular.element(document).ready(function () { 19 | angular.bootstrap(document, ['app']); 20 | angular.element(document.querySelectorAll('.needs-javascript')).removeClass('needs-javascript'); 21 | }); 22 | 23 | // ************************************************************* 24 | // States 25 | // ************************************************************* 26 | 27 | module.config(['$stateProvider', '$urlRouterProvider', '$locationProvider', 28 | function ($stateProvider, $urlRouterProvider, $locationProvider) { 29 | 30 | $stateProvider 31 | // 32 | // Home state 33 | // 34 | .state('home', { 35 | url: '/', 36 | templateUrl: '/templates/home.html', 37 | controller: 'HomeCtrl' 38 | }) 39 | 40 | // 41 | // Settings view 42 | // 43 | .state('home.settings', { 44 | // url: '/detail/:user/:repo', 45 | templateUrl: '/templates/settings.html', 46 | controller: 'SettingsCtrl', 47 | params: { 48 | 'user': {}, 49 | 'owner': {}, 50 | 'repo': {}, 51 | 'gist': {} 52 | } 53 | // params: ['user', 'owner', 'repo', 'gist'] <-- was in older angular version 54 | }) 55 | 56 | // 57 | // My-CLA state 58 | // 59 | .state('cla', { 60 | abstract: true, 61 | url: '/my-cla', 62 | template: '
' 63 | }) 64 | 65 | 66 | .state('cla.myCla', { 67 | url: '', 68 | templateUrl: '/templates/my-cla.html', 69 | controller: 'MyClaCtrl' 70 | }) 71 | 72 | // 73 | // Repo state (abstract) 74 | // 75 | .state('repo', { 76 | abstract: true, 77 | url: '/:user/:repo?pullRequest&redirect', 78 | template: '
' 79 | }) 80 | 81 | // 82 | // Repo cla 83 | // 84 | .state('repo.cla', { 85 | url: '', 86 | templateUrl: '/templates/cla.html', 87 | controller: 'ClaController' 88 | }) 89 | 90 | // 91 | // 404 Error 92 | // 93 | .state('404', { 94 | url: '/404', 95 | templateUrl: '/templates/404.html' 96 | }); 97 | 98 | $urlRouterProvider.otherwise('/404'); 99 | 100 | $locationProvider.html5Mode({ 101 | enabled: true, 102 | requireBase: false 103 | }); 104 | } 105 | ]); 106 | -------------------------------------------------------------------------------- /src/server/api/repo.js: -------------------------------------------------------------------------------- 1 | // module 2 | const repo = require('../services/repo') 3 | const logger = require('../services/logger') 4 | const Joi = require('joi') 5 | const webhook = require('./webhook') 6 | const utils = require('../middleware/utils') 7 | 8 | const REPOCREATESCHEMA = Joi.object().keys({ 9 | owner: Joi.string().required(), 10 | repo: Joi.string().required(), 11 | repoId: Joi.number().required(), 12 | token: Joi.string().required(), 13 | gist: Joi.alternatives().try(Joi.string().uri(), Joi.any().allow([null])), // Null CLA 14 | sharedGist: Joi.boolean(), 15 | minFileChanges: Joi.number(), 16 | minCodeChanges: Joi.number(), 17 | whiteListPattern: Joi.string().allow(''), 18 | privacyPolicy: Joi.string().allow('') 19 | }) 20 | 21 | const REPOREMOVESCHEMA = Joi.alternatives().try(Joi.object().keys({ 22 | owner: Joi.string().required(), 23 | repo: Joi.string().required(), 24 | }), Joi.object().keys({ 25 | repoId: Joi.number().required() 26 | })) 27 | 28 | module.exports = { 29 | check: (req) => repo.check(req.args), 30 | create: async (req) => { 31 | req.args.token = req.args.token || req.user.token 32 | utils.validateArgs(req.args, REPOCREATESCHEMA, true) 33 | 34 | const repoArgs = { 35 | repo: req.args.repo, 36 | owner: req.args.owner, 37 | token: req.args.token 38 | } 39 | let dbRepo 40 | try { 41 | dbRepo = await repo.get(repoArgs) 42 | if (!dbRepo) { 43 | throw 'New repo should be created' 44 | } 45 | try { 46 | const ghRepo = await repo.getGHRepo(repoArgs) 47 | if (ghRepo && ghRepo.id != dbRepo.repoId) { 48 | throw 'Repo id has changed' 49 | } 50 | } catch (error) { 51 | return repo.update(req.args) 52 | } 53 | } catch (error) { 54 | dbRepo = await repo.create(req.args) 55 | 56 | if (dbRepo.gist) { 57 | try { 58 | webhook.create(req) 59 | } catch (error) { 60 | logger.error(`Could not create a webhook for the new repo ${new Error(error)}`) 61 | } 62 | } 63 | return dbRepo 64 | } 65 | throw 'This repository is already linked.' 66 | }, 67 | // get: function(req, done){ 68 | // repo.get(req.args, function(err, found_repo){ 69 | // if (!found_repo || err || found_repo.owner !== req.user.login) { 70 | // log.warn(err) 71 | // done(err) 72 | // return 73 | // } 74 | // done(err, found_repo) 75 | // }) 76 | // }, 77 | getAll: (req) => repo.getAll(req.args), 78 | update: (req) => { 79 | req.args.token = req.args.token || req.user.token 80 | utils.validateArgs(req.args, REPOCREATESCHEMA) 81 | return repo.update(req.args) 82 | }, 83 | remove: async (req) => { 84 | utils.validateArgs(req.args, REPOREMOVESCHEMA) 85 | 86 | const dbRepo = await repo.remove(req.args) 87 | if (dbRepo && dbRepo.gist) { 88 | req.args.owner = dbRepo.owner 89 | req.args.repo = dbRepo.repo 90 | try { 91 | webhook.remove(req) 92 | } catch (error) { 93 | logger.error(`Could not remove the webhook for the repo ${new Error(error)}`) 94 | } 95 | } 96 | return dbRepo 97 | } 98 | } -------------------------------------------------------------------------------- /src/tests/server/services/org.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, beforeEach, afterEach*/ 2 | 3 | // unit test 4 | const assert = require('assert') 5 | const sinon = require('sinon') 6 | 7 | //model 8 | const Org = require('../../../server/documents/org').Org 9 | 10 | // service under test 11 | const org = require('../../../server/services/org') 12 | 13 | // test data 14 | const testData = require('../testData').data 15 | 16 | describe('org:create', async function () { 17 | afterEach(function () { 18 | Org.create.restore() 19 | }) 20 | 21 | it('should create org entry ', async () => { 22 | sinon.stub(Org, 'create').callsFake(async (args) => { 23 | assert(args.orgId) 24 | assert(args.org) 25 | assert(args.gist) 26 | return { org: args.org } 27 | }) 28 | 29 | const arg = { 30 | org: testData.orgs[0].login, 31 | orgId: testData.orgs[0].id, 32 | gist: 'url/gistId', 33 | token: 'abc' 34 | } 35 | await org.create(arg) 36 | }) 37 | 38 | }) 39 | describe('org:update', () => { 40 | afterEach(() => Org.findOne.restore()) 41 | 42 | it('should create org entry ', async () => { 43 | sinon.stub(Org, 'findOne').callsFake(async (args) => { 44 | assert(args.orgId) 45 | const org_entry = { 46 | org: args.org, 47 | save: () => { /*do nothing*/ } 48 | } 49 | sinon.stub(org_entry, 'save').callsFake(async () => { 50 | assert.equal(org_entry.token, 'abc') 51 | }) 52 | return org_entry 53 | }) 54 | 55 | const arg = { 56 | org: testData.orgs[0].login, 57 | orgId: testData.orgs[0].id, 58 | gist: 'url/gistId', 59 | token: 'abc' 60 | } 61 | await org.update(arg) 62 | }) 63 | }) 64 | describe('org:get', () => { 65 | afterEach(() => Org.findOne.restore()) 66 | 67 | it('should find org entry ', async () => { 68 | sinon.stub(Org, 'findOne').callsFake(async (args) => { 69 | assert(args.orgId) 70 | return { org: args.org } 71 | }) 72 | 73 | let args = { 74 | orgId: testData.orgs[0].id, 75 | org: testData.orgs[0].login, 76 | } 77 | 78 | const org_entry = await org.get(args) 79 | assert(org_entry) 80 | }) 81 | }) 82 | describe('org:getMultiple', () => { 83 | it('should find multiple entries', async () => { 84 | sinon.stub(Org, 'find').callsFake(async (args) => { 85 | assert(args.orgId.$in.length > 0) 86 | // assert(args.orgId.$in.length > 0) 87 | return [{}, {}] 88 | }) 89 | 90 | let args = { 91 | orgId: [1, 2] 92 | } 93 | 94 | const res = await org.getMultiple(args) 95 | assert.equal(res.length, 2) 96 | Org.find.restore() 97 | }) 98 | }) 99 | describe('org:remove', () => { 100 | beforeEach(() => { 101 | sinon.stub(Org, 'findOneAndRemove').callsFake(async (args) => { 102 | assert(args.orgId); 103 | return {} 104 | }) 105 | }) 106 | 107 | afterEach(() => Org.findOneAndRemove.restore()) 108 | 109 | it('should find org entry ', async () => { 110 | let args = { 111 | orgId: testData.orgs[0].id, 112 | org: testData.orgs[0].login, 113 | } 114 | const org_entry = await org.remove(args) 115 | assert(org_entry) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | let appJsFiles = [ 3 | 'src/client/app.js', 4 | 'src/client/api.js', 5 | 'src/client/controller/**/*.js', 6 | 'src/client/modals/**/*.js', 7 | 'src/client/services/**/*.js' 8 | ]; 9 | 10 | let config = { 11 | 12 | pkg: grunt.file.readJSON('package.json'), 13 | 14 | coveralls: { 15 | target: { 16 | src: 'output/coverage/lcov.info' 17 | } 18 | }, 19 | 20 | mocha_istanbul: { 21 | coverage: { 22 | src: 'src/tests/server', 23 | options: { 24 | coverage: true, 25 | mask: '**/*.js', 26 | coverageFolder: 'output/coverage' 27 | } 28 | } 29 | }, 30 | 31 | // server tests 32 | mochaTest: { 33 | server: { 34 | options: { 35 | timeout: 1000 36 | }, 37 | src: ['src/tests/server/**/*.js'] 38 | }, 39 | debugServer: { 40 | options: { 41 | timeout: 400000 42 | }, 43 | src: ['src/tests/server/**/*.js'] 44 | } 45 | }, 46 | 47 | // client tests 48 | karma: { 49 | unit: { 50 | configFile: 'src/tests/karma.config.js' 51 | } 52 | }, 53 | 54 | eslint: { 55 | options: { 56 | configFile: './.eslintrc.js' 57 | }, 58 | target: ['*.js', 'src'] 59 | }, 60 | 61 | scsslint: { 62 | allFiles: [ 63 | 'src/client/assets/styles/*.scss' 64 | ], 65 | options: { 66 | config: '.scss-lint.yml', 67 | colorizeOutput: true 68 | } 69 | }, 70 | 71 | watch: { 72 | uglify: { 73 | tasks: ['uglify'], 74 | files: appJsFiles 75 | }, 76 | eslint: { 77 | tasks: ['eslint'], 78 | files: ['src/**/*.js', '!src/client/app.min.js'] 79 | }, 80 | mocha: { 81 | tasks: ['mochaTest:server'], 82 | files: ['src/server/**/*.js', 'src/tests/server/**/*.js', '!src/client/app.min.js'] 83 | }, 84 | karma: { 85 | tasks: ['karma'], 86 | files: ['src/client/**/*.js', 'src/tests/client/**/*.js', '!src/client/app.min.js'] 87 | } 88 | }, 89 | 90 | uglify: { 91 | options: { 92 | sourceMap: true, 93 | sourceMapIncludeSources: true, 94 | mangle: false 95 | }, 96 | target: { 97 | files: { 98 | 'src/client/app.min.js': appJsFiles 99 | } 100 | } 101 | } 102 | }; 103 | 104 | // Initialize configuration 105 | grunt.initConfig(config); 106 | 107 | require('load-grunt-tasks')(grunt); 108 | 109 | grunt.registerTask('build', ['uglify']); 110 | grunt.registerTask('lint', ['eslint', 'scsslint']); 111 | grunt.registerTask('coverage', ['mocha_istanbul']); 112 | grunt.registerTask('default', ['uglify', 'eslint', 'mochaTest:server', 'karma', 'watch']); 113 | grunt.registerTask('test', ['eslint', 'mochaTest:server', 'karma']); 114 | grunt.registerTask('debug_test', ['mochaTest:debugServer']); 115 | }; 116 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cla-assistant", 3 | "author": "https://github.com/cla-assistant", 4 | "version": "1.8.3", 5 | "homepage": "https://cla-assistant.io", 6 | "description": "Contributor license agreement", 7 | "keywords": [ 8 | "license", 9 | "contributor", 10 | "agreement", 11 | "github", 12 | "cla" 13 | ], 14 | "license": "Apache-2.0", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/cla-assistant/cla-assistant" 18 | }, 19 | "bugs": "https://github.com/cla-assistant/cla-assistant/issues", 20 | "contributors": [], 21 | "dependencies": { 22 | "@octokit/plugin-retry": "^2.2.0", 23 | "@octokit/plugin-throttling": "^2.4.0", 24 | "@octokit/rest": "^16.37.0", 25 | "angular": "^1.7.9", 26 | "array-sugar": "^1.2.2", 27 | "async": "^2.6.2", 28 | "base-64": "^0.1.0", 29 | "body-parser": "^1.18.3", 30 | "bower": "^1.8.8", 31 | "btoa": "^1.2.1", 32 | "bunyan": "^1.8.12", 33 | "bunyan-sentry-stream": "^1.2.1", 34 | "bunyan-slack": "0.0.10", 35 | "codeceptjs": "^2.0.7", 36 | "colors": "^1.3.3", 37 | "connect-mongo": "^2.0.3", 38 | "cookie-parser": "^1.4.4", 39 | "dont-sniff-mimetype": "^1.0.0", 40 | "ejs": "^2.6.1", 41 | "express": "^4.16.4", 42 | "express-session": "^1.15.6", 43 | "glob": "^7.1.3", 44 | "joi": "^14.3.1", 45 | "json-stable-stringify": "^1.0.1", 46 | "lodash": "^4.17.13", 47 | "memory-cache": "^0.2.0", 48 | "merge": ">=1.2.1", 49 | "mongoose": "^5.4.19", 50 | "node-fetch": "^2.6.0", 51 | "node-sass-middleware": "^0.11.0", 52 | "passport": "^0.4.0", 53 | "passport-accesstoken": "^0.1.0", 54 | "passport-github": "^1.1.0", 55 | "q": "^1.5.1", 56 | "raven": "^2.6.4", 57 | "request": "^2.88.0", 58 | "request-promise-native": "^1.0.7", 59 | "socket.io": "^2.2.0", 60 | "valid-url": "^1.0.9", 61 | "x-frame-options": "^1.0.0" 62 | }, 63 | "devDependencies": { 64 | "eslint": "^5.15.1", 65 | "grunt": "^1.0.4", 66 | "grunt-cli": "^1.3.2", 67 | "grunt-contrib-jshint": "^2.0.0", 68 | "grunt-contrib-uglify": "^4.0.0", 69 | "grunt-contrib-watch": "^1.1.0", 70 | "grunt-coveralls": "^2.0.0", 71 | "grunt-eslint": "^21.0.0", 72 | "grunt-http": "^2.3.1", 73 | "grunt-karma": "^3.0.1", 74 | "grunt-mocha-istanbul": "^5.0.2", 75 | "grunt-mocha-test": "^0.13.3", 76 | "grunt-scss-lint": "^0.5.0", 77 | "istanbul": "^0.4.5", 78 | "karma": "^4.0.1", 79 | "karma-chrome-launcher": "^2.2.0", 80 | "karma-mocha": "^1.3.0", 81 | "karma-ng-html2js-preprocessor": "~1.0.0", 82 | "karma-phantomjs-launcher": "1.0.4", 83 | "load-grunt-tasks": "^4.0.0", 84 | "mkdirp": "0.5.1", 85 | "mocha": "^6.1.4", 86 | "phantomjs-prebuilt": "^2.1.16", 87 | "rewire": "^4.0.1", 88 | "should": "^13.2.3", 89 | "sinon": "^7.2.7", 90 | "supertest": "4.0.0", 91 | "webdriverio": "^5.7.2" 92 | }, 93 | "engines": { 94 | "node": "12" 95 | }, 96 | "scripts": { 97 | "build": "node_modules/grunt/bin/grunt build", 98 | "start": "node app.js", 99 | "postinstall": "bower install", 100 | "test": "node_modules/grunt/bin/grunt test", 101 | "acceptance_test": "source .env && npx codeceptjs run" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/tests/karma.config.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | 6 | basePath: '../../', 7 | 8 | frameworks: ['mocha'], 9 | 10 | // list of files / patterns to load in the browser 11 | files: [ 12 | // Testing libs 13 | // CDN 14 | 'http://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js', 15 | 'http://cdnjs.cloudflare.com/ajax/libs/angular.js/1.7.0/angular.min.js', 16 | 'http://cdnjs.cloudflare.com/ajax/libs/angular.js/1.7.0/angular-animate.min.js', 17 | 'http://cdnjs.cloudflare.com/ajax/libs/angular.js/1.7.0/angular-route.js', 18 | 'http://cdnjs.cloudflare.com/ajax/libs/angular-ui-router/1.0.17/angular-ui-router.min.js', 19 | 'http://cdnjs.cloudflare.com/ajax/libs/angular-ui-bootstrap/0.13.4/ui-bootstrap-tpls.min.js', 20 | 'http://cdnjs.cloudflare.com/ajax/libs/angular-ui-select/0.19.8/select.min.js', 21 | 'http://cdnjs.cloudflare.com/ajax/libs/angular-ui-utils/0.1.1/angular-ui-utils.min.js', 22 | 'http://cdnjs.cloudflare.com/ajax/libs/angular.js/1.7.0/angular-sanitize.min.js', 23 | 'http://cdnjs.cloudflare.com/ajax/libs/angular-scroll/0.6.5/angular-scroll.min.js', 24 | 'http://cdnjs.cloudflare.com/ajax/libs/ng-csv/0.3.3/ng-csv.min.js', 25 | 26 | 'http://cdnjs.cloudflare.com/ajax/libs/sinon.js/1.7.3/sinon-min.js', 27 | 28 | 'src/bower/should/should.js', 29 | 30 | // Bower 31 | 'src/bower/bootstrap-sass-official/assets/javascripts/bootstrap.js', 32 | 'src/bower/angular-mocks/angular-mocks.js', 33 | 34 | // Client code 35 | 'src/client/app.js', 36 | 'src/client/api.js', 37 | 'src/client/services/**/*.js', 38 | 'src/client/controller/**/*.js', 39 | 40 | // Client templates 41 | 'src/client/**/*.html', 42 | 43 | // Tests 44 | 'src/tests/client/**/*.js' 45 | ], 46 | 47 | 48 | // list of files to exclude 49 | exclude: [ 50 | 51 | ], 52 | 53 | 54 | // preprocess matching files before serving them to the browser 55 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 56 | preprocessors: { 57 | 'src/client/**/*.html': ['ng-html2js'] 58 | }, 59 | 60 | ngHtml2JsPreprocessor: { 61 | stripPrefix: 'src/client', 62 | moduleName: 'templates' 63 | }, 64 | 65 | 66 | // test results reporter to use 67 | // possible values: 'dots', 'progress' 68 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 69 | reporters: ['dots'], 70 | 71 | 72 | // web server port 73 | port: 9876, 74 | 75 | // to avoid DISCONNECTED messages 76 | browserDisconnectTimeout: 10000, // default 2000 77 | browserDisconnectTolerance: 1, // default 0 78 | browserNoActivityTimeout: 60000, //default 10000 79 | 80 | // enable / disable colors in the output (reporters and logs) 81 | colors: true, 82 | 83 | 84 | // level of logging 85 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 86 | logLevel: config.LOG_INFO, 87 | 88 | 89 | // enable / disable watching file and executing tests whenever any file changes 90 | autoWatch: true, 91 | 92 | 93 | // start these browsers 94 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 95 | browsers: ['PhantomJS'], 96 | // browsers: ['Chrome'], 97 | plugins: [ 98 | 'karma-ng-html2js-preprocessor', 99 | 'karma-mocha', 100 | // 'karma-chrome-launcher' 101 | 'karma-phantomjs-launcher', 102 | ], 103 | 104 | 105 | // Continuous Integration mode 106 | // if true, Karma captures browsers, runs the tests and exits 107 | singleRun: true 108 | }); 109 | }; 110 | -------------------------------------------------------------------------------- /src/client/assets/images/feature1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 17 | 19 | 21 | 23 | 25 | 27 | 29 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/tests/server/services/github.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, beforeEach*/ 2 | 3 | // unit test 4 | const rewire = require('rewire') 5 | const assert = require('assert') 6 | const sinon = require('sinon') 7 | 8 | // config 9 | global.config = require('../../../config') 10 | 11 | // service 12 | const github = rewire('../../../server/services/github') 13 | const cache = require('memory-cache') 14 | 15 | const callStub = sinon.stub() 16 | const authenticateStub = sinon.stub() 17 | 18 | describe('github:call', () => { 19 | let expectedAuth 20 | function OctokitMock(args) { 21 | assert.deepStrictEqual(args.auth, expectedAuth) 22 | assert.strictEqual(args.protocol, 'https') 23 | assert.strictEqual(args.version, '3.0.0') 24 | assert.strictEqual(args.host, 'api.github.com') 25 | assert.strictEqual(args.pathPrefix, null) 26 | 27 | this.obj = { 28 | fun: callStub, 29 | listSomething: { 30 | endpoint: { 31 | merge: function (args) { 32 | return args 33 | } 34 | } 35 | } 36 | } 37 | 38 | this.authenticate = authenticateStub 39 | 40 | this.paginate = callStub 41 | } 42 | OctokitMock.plugin = sinon.stub().returns(OctokitMock) 43 | 44 | github.__set__('Octokit', OctokitMock) 45 | 46 | beforeEach(() => { 47 | github.resetList = {} 48 | callStub.reset() 49 | cache.clear() 50 | expectedAuth = undefined 51 | authenticateStub.reset() 52 | }) 53 | 54 | it('should return an error if obj is not set', async () => { 55 | try { 56 | await github.call({}) 57 | assert(false, 'Should throw an error') 58 | } catch (error) { 59 | assert.equal(error.message, 'obj required/obj not found') 60 | } 61 | }) 62 | 63 | it('should return an error if fun is not set', async () => { 64 | try { 65 | await github.call({ obj: 'obj' }) 66 | assert(false, 'Should throw an error') 67 | } catch (error) { 68 | assert.equal(error.message, 'fun required/fun not found') 69 | } 70 | }) 71 | 72 | it('should authenticate when token is set', async () => { 73 | expectedAuth = 'token token' 74 | callStub.resolves({ data: {}, meta: {} }) 75 | 76 | await github.call({ 77 | obj: 'obj', 78 | fun: 'fun', 79 | token: 'token' 80 | }) 81 | }) 82 | 83 | it('should authenticate when basic authentication is required', async () => { 84 | expectedAuth = { 85 | username: 'user', 86 | password: 'pass' 87 | } 88 | callStub.resolves({ data: {}, meta: {} }) 89 | await github.call({ 90 | obj: 'obj', 91 | fun: 'fun', 92 | basicAuth: { 93 | user: 'user', 94 | pass: 'pass' 95 | } 96 | }) 97 | }) 98 | 99 | it('should not authenticate when neither token nor basicAuth are provided', async () => { 100 | expectedAuth = undefined 101 | callStub.resolves({ data: {}, meta: {} }) 102 | await github.call({ 103 | obj: 'obj', 104 | fun: 'fun' 105 | }) 106 | }) 107 | 108 | it('should call the appropriate function on the github api', async () => { 109 | const testHeaders = { 110 | link: null, 111 | 'x-oauth-scopes': [] 112 | } 113 | callStub.resolves({ data: {}, headers: testHeaders }) 114 | const res = await github.call({ 115 | obj: 'obj', 116 | fun: 'fun' 117 | }) 118 | assert.deepStrictEqual(res, { data: {}, headers: testHeaders }) 119 | }) 120 | 121 | it('should return github error', async () => { 122 | callStub.rejects('github error') 123 | try { 124 | await github.call({ 125 | obj: 'obj', 126 | fun: 'fun' 127 | }) 128 | assert(false, 'Should throw an error') 129 | } catch (error) { 130 | assert.equal(error, 'github error') 131 | } 132 | }) 133 | }) -------------------------------------------------------------------------------- /src/client/modals/upload.js: -------------------------------------------------------------------------------- 1 | module.controller('UploadCtrl', 2 | function ($scope, $modalInstance, customFields) { 3 | 4 | $scope.json = {}; 5 | $scope.availableFieldKeys = ['user', 'created_at']; 6 | $scope.selectedKeys = {}; 7 | 8 | $scope.availableFieldKeys = $scope.availableFieldKeys.concat(customFields); 9 | 10 | $scope.selectedKeyList = function () { 11 | return Object.keys($scope.selectedKeys).map(function (i) { return $scope.selectedKeys[i]; }); 12 | }; 13 | 14 | $scope.canBeUploaded = function () { 15 | return $scope.selectedKeyList().indexOf('user') != -1; 16 | }; 17 | 18 | $scope.validateAttribute = function (data, index) { 19 | if ($scope.selectedKeys[index] === 'created_at') { 20 | return isNaN(Date.parse(data)) ? 'danger' : 'success'; 21 | } else if ($scope.selectedKeys[index] === 'user') { 22 | return typeof data === 'string' ? 'success' : 'danger'; 23 | } 24 | }; 25 | 26 | $scope.$watch('file', function () { 27 | if ($scope.file) { 28 | Papa.parse($scope.file, { 29 | complete: function (data) { 30 | $scope.json = data; 31 | } 32 | }); 33 | } 34 | }); 35 | 36 | $scope.upload = function () { 37 | 38 | var data = []; 39 | if ($scope.header) { 40 | $scope.json.data.splice(0, 1); 41 | } 42 | data = $scope.json.data.map(function (line) { 43 | var argsToUpload = {}; 44 | try { 45 | Object.keys($scope.selectedKeys).forEach(function (index) { 46 | var attributeName = $scope.selectedKeys[index]; 47 | if (attributeName === 'user') { 48 | argsToUpload.user = line[index]; 49 | } else if (attributeName === 'created_at') { 50 | if (isNaN(Date.parse(line[index]))) { 51 | throw new Error('unsupported date format'); 52 | } 53 | argsToUpload.created_at = new Date(line[index]).toUTCString(); 54 | } else { 55 | argsToUpload.custom_fields = argsToUpload.custom_fields ? argsToUpload.custom_fields : {}; 56 | argsToUpload.custom_fields[attributeName] = line[index]; 57 | } 58 | }); 59 | } catch (e) { 60 | // eslint-disable-next-line no-console 61 | console.log(e); 62 | 63 | return; 64 | } 65 | argsToUpload.custom_fields = JSON.stringify(argsToUpload.custom_fields); 66 | 67 | return argsToUpload; 68 | }).filter(function (line) { return line; }); 69 | 70 | $modalInstance.close(data); 71 | }; 72 | 73 | $scope.cancel = function () { 74 | $modalInstance.dismiss('cancel'); 75 | }; 76 | } 77 | ) 78 | .directive('fileModel', function () { 79 | return { 80 | scope: { 81 | fileModel: '=' 82 | }, 83 | link: function (scope, elem) { 84 | elem.bind('change drop', function (change) { 85 | var reader = new FileReader(); 86 | reader.onload = function (load) { 87 | scope.$apply(function () { 88 | scope.fileModel = load.target.result; 89 | }); 90 | }; 91 | reader.readAsText(change.target.files[0]); 92 | }); 93 | } 94 | }; 95 | }); 96 | 97 | filters.filter('notSelected', function () { 98 | return function (items, arr) { 99 | 100 | if (!arr || arr.length === 0) { 101 | return items; 102 | } 103 | 104 | var notMatched = []; 105 | 106 | items.forEach(function (item) { 107 | if (arr.indexOf(item) < 0) { 108 | notMatched.push(item); 109 | } 110 | }); 111 | 112 | return notMatched; 113 | }; 114 | }); -------------------------------------------------------------------------------- /src/client/assets/images/nervous_remove/nervous-36.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 37 | 38 | 41 | 43 | 46 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/client/modals/templates/report.html: -------------------------------------------------------------------------------- 1 | 8 | 61 | 62 | -------------------------------------------------------------------------------- /src/client/assets/images/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 37 | 38 | 41 | 43 | 46 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 64 | 65 | 66 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/client/templates/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 14 |
15 | 16 |
17 |
18 | 19 | {{getGistName()}} 21 | {{getGistName()}} 23 | 24 | Disabled 25 |
26 |
27 | 28 | 36 | 37 | 38 | 39 | 40 | 41 | 44 | 45 |
46 | {{ item.sharedGist ? 'Yes' : 'No' }} 47 |
48 | 49 |
50 | {{signatures.value.length}} 52 |
53 | 54 | 63 |
64 | 67 |
68 | 69 | 76 |
78 | 80 |
81 | 83 |
84 |
85 | -------------------------------------------------------------------------------- /src/server/services/github.js: -------------------------------------------------------------------------------- 1 | const request = require('request-promise-native') 2 | 3 | const cache = require('memory-cache') 4 | const config = require('../../config') 5 | let Octokit = require('@octokit/rest') 6 | .plugin(require('@octokit/plugin-throttling')) 7 | .plugin(require('@octokit/plugin-retry')) 8 | const stringify = require('json-stable-stringify') 9 | const logger = require('../services/logger') 10 | 11 | async function callGithub(octokit, obj, fun, arg, cacheKey) { 12 | const cachedRes = arg.noCache ? null : cache.get(cacheKey) 13 | delete arg.noCache 14 | if (cachedRes && config.server.cache_time > 0) { 15 | return cachedRes 16 | } 17 | let res 18 | if (fun.match(/list.*/g)) { 19 | const options = octokit[obj][fun].endpoint.merge(arg) 20 | res = { 21 | data: await octokit.paginate(options) 22 | } 23 | } else { 24 | res = await octokit[obj][fun](arg) 25 | } 26 | 27 | if (res && config.server.cache_time > 0) { 28 | cache.put(cacheKey, res, 60000 * config.server.cache_time) 29 | } 30 | 31 | return res 32 | } 33 | 34 | function newOctokit(auth) { 35 | Octokit = addReposGetByIdEndpoint() 36 | return new Octokit({ 37 | auth, 38 | protocol: config.server.github.protocol, 39 | version: config.server.github.version, 40 | host: config.server.github.api, 41 | pathPrefix: config.server.github.enterprise ? '/api/v3' : null, 42 | throttle: { 43 | onRateLimit: (retryAfter, options) => { 44 | // eslint-disable-next-line no-console 45 | console.warn(`Request quota exhausted for request ${options.method} ${options.url}`) 46 | 47 | if (options.request.retryCount === 0) { // only retries once 48 | // eslint-disable-next-line no-console 49 | console.log(`Retrying after ${retryAfter} seconds!`) 50 | return true 51 | } 52 | }, 53 | onAbuseLimit: (retryAfter, options) => { 54 | // does not retry, only logs a warning 55 | // eslint-disable-next-line no-console 56 | console.warn(`Abuse detected for request ${options.method} ${options.url}`) 57 | } 58 | } 59 | }) 60 | } 61 | 62 | function addReposGetByIdEndpoint() { 63 | return Octokit.plugin((octokit) => { 64 | octokit.registerEndpoints({ 65 | repos: { 66 | getById: { 67 | method: 'GET', 68 | url: '/repositories/:id', 69 | params: { 70 | id: { 71 | type: 'string', 72 | required: true 73 | } 74 | } 75 | } 76 | } 77 | }) 78 | }) 79 | } 80 | 81 | const githubService = { 82 | resetList: {}, 83 | 84 | call: async (call) => { 85 | const arg = call.arg || {} 86 | const basicAuth = call.basicAuth 87 | const fun = call.fun 88 | const obj = call.obj 89 | const token = call.token 90 | 91 | const argWithoutNoCache = Object.assign({}, arg) 92 | delete argWithoutNoCache.noCache 93 | 94 | const stringArgs = stringify({ 95 | obj: call.obj, 96 | fun: call.fun, 97 | arg: argWithoutNoCache, 98 | token: call.token 99 | }) 100 | 101 | let auth 102 | if (token) { 103 | auth = `token ${token}` 104 | } 105 | if (basicAuth) { 106 | auth = { 107 | username: basicAuth.user, 108 | password: basicAuth.pass 109 | } 110 | } 111 | const octokit = newOctokit(auth) 112 | 113 | if (!obj || !octokit[obj]) { 114 | throw new Error('obj required/obj not found') 115 | } 116 | 117 | if (!fun || !octokit[obj][fun]) { 118 | throw new Error('fun required/fun not found') 119 | } 120 | try { 121 | return callGithub(octokit, obj, fun, arg, stringArgs) 122 | } catch (error) { 123 | logger.info(`${error} - Error on callGithub.${obj}.${fun} with args ${arg}.`) 124 | throw new Error(error) 125 | } 126 | }, 127 | 128 | callGraphql: async (query, token) => { 129 | return request.post({ 130 | headers: { 131 | 'Authorization': `bearer ${token}`, 132 | 'User-Agent': 'CLA assistant' 133 | }, 134 | url: config.server.github.graphqlEndpoint, 135 | body: query 136 | }) 137 | } 138 | } 139 | 140 | module.exports = githubService -------------------------------------------------------------------------------- /src/server/api/org.js: -------------------------------------------------------------------------------- 1 | const org = require('../services/org') 2 | const github = require('../services/github') 3 | const log = require('../services/logger') 4 | const Joi = require('joi') 5 | const webhook = require('./webhook') 6 | const logger = require('../services/logger') 7 | const utils = require('../middleware/utils') 8 | //queries 9 | const queries = require('../graphQueries/github') 10 | const newOrgSchema = Joi.object().keys({ 11 | orgId: Joi.number().required(), 12 | org: Joi.string().required(), 13 | gist: Joi.string().required(), 14 | token: Joi.string().required(), 15 | excludePattern: Joi.string(), 16 | sharedGist: Joi.boolean(), 17 | minFileChanges: Joi.number(), 18 | minCodeChanges: Joi.number(), 19 | whiteListPattern: Joi.string().allow(''), 20 | privacyPolicy: Joi.string().allow('') 21 | }) 22 | const removeOrgSchema = Joi.object().keys({ 23 | org: Joi.string(), 24 | orgId: Joi.number() 25 | }).or('org', 'orgId') 26 | 27 | class OrgAPI { 28 | async create(req) { 29 | req.args.token = req.args.token || req.user.token 30 | utils.validateArgs(req.args, newOrgSchema, true) 31 | 32 | const query = { 33 | orgId: req.args.orgId, 34 | org: req.args.org, 35 | } 36 | let dbOrg 37 | try { 38 | dbOrg = await org.get(query) 39 | } catch (error) { 40 | logger.info(new Error(error).stack) 41 | } 42 | 43 | if (dbOrg) { 44 | throw new Error('This org is already linked.') 45 | } 46 | dbOrg = await org.create(req.args) 47 | 48 | try { 49 | await webhook.create(req) 50 | } catch (error) { 51 | logger.error(new Error(error).stack) 52 | } 53 | return dbOrg 54 | } 55 | 56 | async update(req) { 57 | req.args.token = req.args.token || req.user.token 58 | utils.validateArgs(req.args, newOrgSchema, true) 59 | 60 | return org.update(req.args) 61 | } 62 | 63 | async getForUser(req) { 64 | try { 65 | const res = await this.getGHOrgsForUser(req) 66 | const argsForOrg = { 67 | orgId: res.map((org) => org.id) 68 | } 69 | return org.getMultiple(argsForOrg) 70 | } catch (error) { 71 | log.warn(error.stack) 72 | throw error 73 | } 74 | } 75 | 76 | async getGHOrgsForUser(req) { 77 | let organizations = [] 78 | 79 | async function callGithub(arg) { 80 | const query = arg.query ? arg.query : queries.getUserOrgs(req.user.login, null) 81 | 82 | try { 83 | let body = await github.callGraphql(query, req.user.token) 84 | body = JSON.parse(body) 85 | 86 | if (body.errors) { 87 | const errorMessage = body.errors[0] && body.errors[0].message ? body.errors[0].message : 'Error occurred by getting users organizations' 88 | logger.info(new Error(errorMessage).stack) 89 | } 90 | 91 | const data = body.data.user.organizations 92 | 93 | organizations = data.edges.reduce((orgs, edge) => { 94 | if (edge.node.viewerCanAdminister) { 95 | edge.node.id = edge.node.databaseId 96 | orgs.push(edge.node) 97 | } 98 | 99 | return orgs 100 | }, organizations) 101 | 102 | if (data.pageInfo.hasNextPage) { 103 | arg.query = queries.getUserOrgs(req.user.login, data.pageInfo.endCursor) 104 | return callGithub(arg) 105 | } 106 | return organizations 107 | } catch (error) { 108 | log.info(new Error(error).stack) 109 | log.warn(`No result on GH call, getting user orgs! For user: ${req.user}`) 110 | throw error 111 | } 112 | } 113 | if (req.user && req.user.login) { 114 | return callGithub({}) 115 | } 116 | 117 | throw new Error('User is undefined') 118 | } 119 | // update(req, done){ 120 | // org.update(req.args, done) 121 | // } 122 | async remove(req) { 123 | utils.validateArgs(req.args, removeOrgSchema) 124 | const dbOrg = await org.remove(req.args) 125 | if (!dbOrg) { 126 | throw new Error('Organization is not Found') 127 | } 128 | req.args.org = dbOrg.org 129 | try { 130 | await webhook.remove(req) 131 | } catch (error) { 132 | logger.warn(new Error(error)) 133 | } 134 | return dbOrg 135 | } 136 | } 137 | 138 | module.exports = new OrgAPI() -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration Module 3 | * 4 | * @title config 5 | * @overview Configuration Module 6 | */ 7 | let path = require('path'); 8 | 9 | module.exports = { 10 | server: { 11 | github: { 12 | // optional 13 | protocol: process.env.GITHUB_PROTOCOL || 'https', 14 | host: process.env.GITHUB_HOST || 'github.com', 15 | api: process.env.GITHUB_API_HOST || 'api.github.com', 16 | enterprise: !!process.env.GITHUB_HOST, // flag enterprise version 17 | version: process.env.GITHUB_VERSION || '3.0.0', 18 | 19 | graphqlEndpoint: process.env.GITHUB_GRAPHQL || 'https://api.github.com/graphql', 20 | 21 | // required 22 | client: process.env.GITHUB_CLIENT, 23 | secret: process.env.GITHUB_SECRET, 24 | 25 | // required 26 | user: process.env.GITHUB_USER, 27 | pass: process.env.GITHUB_PASS, 28 | admin_users: process.env.GITHUB_ADMIN_USERS ? process.env.GITHUB_ADMIN_USERS.split(/\s*,\s*/) : [], 29 | 30 | // required 31 | token: process.env.GITHUB_TOKEN, 32 | 33 | user_scope: ['user:email'], 34 | admin_scope: ['user:email', 'repo:status', 'read:repo_hook', 'write:repo_hook', 'read:org', 'gist'], 35 | 36 | commit_bots: ['web-flow'], 37 | 38 | //delay reaction on webhook 39 | enforceDelay: parseInt(process.env.GITHUB_DELAY || '5000', 10), 40 | 41 | //slow down API calls in order to avoid abuse rate limit 42 | timeToWait: process.env.GITHUB_TIME_TO_WAIT || 1000 43 | }, 44 | 45 | localport: process.env.PORT || 5000, 46 | 47 | always_recompile_sass: process.env.NODE_ENV === 'production' ? false : true, 48 | 49 | cache_time: process.env.CACHE_TIME || 5, 50 | 51 | http: { 52 | protocol: process.env.PROTOCOL || 'http', 53 | host: process.env.HOST || 'cla-assistant.io', 54 | port: process.env.HOST_PORT 55 | }, 56 | 57 | security: { 58 | sessionSecret: process.env.SESSION_SECRET || 'cla-assistant', 59 | cookieMaxAge: 60 * 60 * 1000 60 | }, 61 | 62 | smtp: { 63 | enabled: !!process.env.SMTP_HOST, 64 | host: process.env.SMTP_HOST, 65 | secure: (!!process.env.SMTP_SSL && process.env.SMTP_SSL === 'true'), 66 | port: process.env.SMTP_PORT || 465, 67 | auth: { 68 | user: process.env.SMTP_USER, 69 | pass: process.env.SMTP_PASS 70 | }, 71 | name: 'cla-assistant' 72 | }, 73 | 74 | mongodb: { 75 | uri: process.env.MONGODB || process.env.MONGOLAB_URI 76 | }, 77 | 78 | slack: { 79 | url: process.env.SLACK_URL, 80 | channel: process.env.SLACK_CHANNEL 81 | }, 82 | 83 | templates: { 84 | login: process.env.LOGIN_PAGE_TEMPLATE || path.join(__dirname, 'client', 'login.html') 85 | }, 86 | 87 | api_access: { 88 | free: ['/api/cla/get', '/api/cla/getLinkedItem'], 89 | external: ['/api/cla/getAll'], 90 | admin_only: [ 91 | '/api/cla/addSignature', 92 | '/api/cla/hasSignature', 93 | '/api/cla/terminateSignature', 94 | '/api/cla/validate', 95 | '/api/cla/getGist', 96 | '/api/org/create', 97 | '/api/org/remove', 98 | '/api/repo/create', 99 | '/api/repo/remove' 100 | ] 101 | }, 102 | 103 | feature_flag: { 104 | required_signees: process.env.REQUIRED_SIGNEES || '', 105 | organization_override_enabled: process.env.ORG_OVERRIDE_ENABLED || false, 106 | }, 107 | 108 | static: [ 109 | path.join(__dirname, 'bower'), 110 | path.join(__dirname, 'client') 111 | ], 112 | 113 | api: [ 114 | path.join(__dirname, 'server', 'api', '*.js') 115 | ], 116 | 117 | webhooks: [ 118 | path.join(__dirname, 'server', 'webhooks', '*.js') 119 | ], 120 | 121 | documents: [ 122 | path.join(__dirname, 'server', 'documents', '*.js') 123 | ], 124 | 125 | controller: [ 126 | path.join(__dirname, 'server', 'controller', '!(default).js'), 127 | path.join(__dirname, 'server', 'controller', 'default.js') 128 | ], 129 | 130 | graphQueries: [ 131 | path.join(__dirname, 'server', 'graphQueries', '*.js') 132 | ], 133 | 134 | middleware: [ 135 | path.join(__dirname, 'server', 'middleware', '*.js') 136 | ], 137 | 138 | passport: [ 139 | path.join(__dirname, 'server', 'passports', '*.js') 140 | ], 141 | 142 | } 143 | }; -------------------------------------------------------------------------------- /src/client/assets/images/feature2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 69 | 70 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /src/server/passports/github.js: -------------------------------------------------------------------------------- 1 | const url = require('../services/url') 2 | const repoService = require('../services/repo') 3 | const orgApi = require('../api/org') 4 | const logger = require('../services/logger') 5 | const passport = require('passport') 6 | const Strategy = require('passport-github').Strategy 7 | const merge = require('merge') 8 | const User = require('mongoose').model('User') 9 | const fetch = require('node-fetch') 10 | const base64 = require('base-64') 11 | 12 | function updateToken(item, newToken) { 13 | item.token = newToken 14 | item.save() 15 | logger.debug('Update access token for repo / org', item.repo || item.org) 16 | } 17 | 18 | async function checkToken(item, accessToken) { 19 | const baseURL = `https://api.github.com` 20 | const checkAuthApi = `${ baseURL }/applications/${ config.server.github.client }/token` 21 | const newToken = accessToken 22 | const oldToken = item.token 23 | const accesstokenObject = { 24 | access_token: oldToken 25 | } 26 | 27 | try { 28 | const header = { 29 | method: 'POST', 30 | headers: { 31 | 'Authorization': 'Basic ' + base64.encode(config.server.github.client + ":" + config.server.github.secret), 32 | 'User-Agent': 'CLA assistant', 33 | 'Accept': 'application/vnd.github.doctor-strange-preview+json', 34 | 'Content-Type': 'application/json' 35 | }, 36 | body: JSON.stringify(accesstokenObject) 37 | } 38 | const res = await fetch(checkAuthApi, header) 39 | const checkTokenResponse = await res.json() 40 | if (checkTokenResponse) { 41 | if (!(checkTokenResponse && checkTokenResponse.scopes && checkTokenResponse.scopes.indexOf('write:repo_hook') >= 0)) { 42 | updateToken(item, newToken) 43 | } else if (item.repo) { 44 | const ghRepo = await repoService.getGHRepo(item) 45 | if (!(ghRepo && ghRepo.permissions && ghRepo.permissions.admin)) { 46 | updateToken(item, newToken) 47 | logger.info('Update access token for repo ', item.repo, ' admin rights have been changed') 48 | } 49 | } 50 | } 51 | } catch (error) { 52 | updateToken(item, newToken) 53 | } 54 | 55 | } 56 | 57 | passport.use(new Strategy({ 58 | clientID: config.server.github.client, 59 | clientSecret: config.server.github.secret, 60 | callbackURL: url.githubCallback, 61 | authorizationURL: url.githubAuthorization, 62 | tokenURL: url.githubToken, 63 | userProfileURL: url.githubProfile() 64 | }, async (accessToken, _refreshToken, params, profile, done) => { 65 | let user 66 | try { 67 | user = await User.findOne({ 68 | name: profile.username 69 | }) 70 | 71 | if (user && !user.uuid) { 72 | user.uuid = profile.id 73 | } 74 | user.token = accessToken 75 | user.save() 76 | } catch (error) { 77 | logger.warn(error.stack) 78 | } 79 | 80 | if (!user) { 81 | try { 82 | await User.create({ 83 | uuid: profile.id, 84 | name: profile.username, 85 | token: accessToken 86 | }) 87 | } catch (error) { 88 | logger.warn(new Error(`Could not create new user ${error}`).stack) 89 | } 90 | } 91 | // User.update({ 92 | // uuid: profile.id 93 | // }, { 94 | // name: profile.username, 95 | // email: '', // needs fix 96 | // token: accessToken 97 | // }, { 98 | // upsert: true 99 | // }, function () {}) 100 | 101 | if (params.scope.indexOf('write:repo_hook') >= 0) { 102 | try { 103 | const repoRes = await repoService.getUserRepos({ 104 | token: accessToken 105 | }) 106 | if (repoRes && repoRes.length > 0) { 107 | repoRes.forEach((repo) => checkToken(repo, accessToken)) 108 | } 109 | } catch (error) { 110 | logger.warn(new Error(error).stack) 111 | } 112 | } 113 | if (params.scope.indexOf('admin:org_hook') >= 0) { 114 | try { 115 | const orgRes = await orgApi.getForUser({ 116 | user: { 117 | token: accessToken, 118 | login: profile.username 119 | } 120 | }) 121 | if (orgRes && orgRes.length > 0) { 122 | orgRes.forEach((org) => checkToken(org, accessToken)) 123 | } 124 | } catch (error) { 125 | logger.warn(new Error(error).stack) 126 | } 127 | } 128 | done(null, merge(profile._json, { 129 | token: accessToken, 130 | scope: params.scope 131 | })) 132 | })) 133 | 134 | passport.serializeUser((user, done) => done(null, user)) 135 | 136 | passport.deserializeUser((user, done) => done(null, user)) --------------------------------------------------------------------------------