├── docs ├── CONTRIBUTING.md └── README.md ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── dashboard-app ├── client │ ├── src │ │ ├── theme │ │ │ └── index.js │ │ ├── fonts │ │ │ ├── Lato-Bold.ttf │ │ │ ├── Lato-Light.ttf │ │ │ └── Lato-Regular.ttf │ │ ├── components │ │ │ ├── FullWidthDiv.js │ │ │ ├── Result.js │ │ │ ├── SearchOption.js │ │ │ ├── Input.js │ │ │ ├── FilterOption.js │ │ │ ├── Footer.js │ │ │ ├── ListItem.js │ │ │ ├── PrResults.js │ │ │ ├── FilenameResults.js │ │ │ ├── Tabs.js │ │ │ ├── Repos.js │ │ │ ├── Search.js │ │ │ └── Pareto.js │ │ ├── App.test.mjs │ │ ├── index.js │ │ ├── constants │ │ │ └── index.js │ │ ├── index.css │ │ ├── App.js │ │ ├── serviceWorker.js │ │ └── assets │ │ │ └── freeCodeCampLogo.js │ ├── package.json │ └── public │ │ └── index.html └── server │ ├── routes │ ├── index.js │ ├── info.js │ ├── all-repos.js │ ├── pareto.js │ ├── pr.js │ └── search.js │ ├── req-limiter.js │ ├── tools │ ├── get-prs.js │ ├── getFilenames.js │ ├── get-repos.js │ └── update-db.js │ ├── models │ └── index.js │ ├── package.json │ └── index.js ├── lib ├── validation │ ├── index.js │ └── valid-labels.js ├── utils │ ├── open-json-file.js │ ├── save-to-file.js │ ├── rate-limiter.js │ ├── index.js │ ├── save-pr-data.js │ └── processing-log.js ├── pr-tasks │ ├── index.js │ ├── add-labels.js │ ├── add-comment.js │ ├── labeler.js │ └── close-open.js ├── package.json ├── config.js └── get-prs │ ├── pr-stats.js │ └── index.js ├── lerna.json ├── sample.env ├── README.md ├── one-off-scripts ├── package.json ├── close-open-specific-failures.js ├── add-language-labels-to-files.js ├── comments-and-labels-summary.js ├── sweeper.js ├── add-test-locally-label.js ├── find-failures.js ├── get-unknown-repo-prs.js ├── prs-with-merge-conflicts.js ├── add-comment-on-frontmatter-issues.js └── package-lock.json ├── LICENSE.md ├── package.json └── .gitignore /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Todo 4 | 5 | ## Usage 6 | 7 | Todo 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | > Our Code of Conduct is available here: 2 | -------------------------------------------------------------------------------- /dashboard-app/client/src/theme/index.js: -------------------------------------------------------------------------------- 1 | const theme = { 2 | primary: '#0a0a23' 3 | }; 4 | 5 | export default theme; 6 | -------------------------------------------------------------------------------- /lib/validation/index.js: -------------------------------------------------------------------------------- 1 | const { validLabels } = require('./valid-labels'); 2 | 3 | module.exports = { validLabels }; 4 | -------------------------------------------------------------------------------- /dashboard-app/client/src/fonts/Lato-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/tools-dashboard/HEAD/dashboard-app/client/src/fonts/Lato-Bold.ttf -------------------------------------------------------------------------------- /dashboard-app/client/src/fonts/Lato-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/tools-dashboard/HEAD/dashboard-app/client/src/fonts/Lato-Light.ttf -------------------------------------------------------------------------------- /dashboard-app/client/src/fonts/Lato-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/tools-dashboard/HEAD/dashboard-app/client/src/fonts/Lato-Regular.ttf -------------------------------------------------------------------------------- /dashboard-app/client/src/components/FullWidthDiv.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const FullWidthDiv = styled.div` 4 | width: 100%; 5 | `; 6 | 7 | export default FullWidthDiv; 8 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "dashboard-app", 4 | "dashboard-app/client", 5 | "dashboard-app/server", 6 | "lib", 7 | "one-off-scripts" 8 | ], 9 | "version": "independent" 10 | } 11 | -------------------------------------------------------------------------------- /lib/utils/open-json-file.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const openJSONFile = (fileName) => { 4 | const data = JSON.parse(fs.readFileSync(fileName, 'utf8')); 5 | return data; 6 | }; 7 | 8 | module.exports = { openJSONFile }; 9 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | GITHUB_USERNAME='camperbot' 2 | GITHUB_ACCESS_TOKEN= 3 | REPOSITORY_OWNER='freeCodeCamp' 4 | REPOSITORY='freeCodeCamp' 5 | DEFAULT_BASE='main' 6 | PORT=3001 7 | MONGO_HOST=mongodb://localhost/contributor-tools 8 | MONGO_PORT=27017 9 | PRODUCTION_RUN=false 10 | -------------------------------------------------------------------------------- /dashboard-app/server/routes/index.js: -------------------------------------------------------------------------------- 1 | const pareto = require('./pareto'); 2 | const pr = require('./pr'); 3 | const search = require('./search'); 4 | const info = require('./info'); 5 | const allRepos = require('./all-repos'); 6 | 7 | module.exports = { pareto, pr, search, info, allRepos }; 8 | -------------------------------------------------------------------------------- /lib/pr-tasks/index.js: -------------------------------------------------------------------------------- 1 | const { addComment } = require('./add-comment'); 2 | const { addLabels } = require('./add-labels'); 3 | const { closeOpen } = require('./close-open'); 4 | const { labeler } = require('./labeler'); 5 | 6 | module.exports = { addComment, addLabels, closeOpen, labeler }; 7 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/Result.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Result = styled.div` 4 | border: 1px solid #aaa; 5 | margin: 10px 0; 6 | &:nth-child(odd) { 7 | background: #eee; 8 | } 9 | padding: 3px; 10 | `; 11 | 12 | export default Result; 13 | -------------------------------------------------------------------------------- /lib/utils/save-to-file.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const saveToFile = (fileName, data) => { 4 | fs.writeFileSync(fileName, data, (err) => { 5 | if (err) { 6 | return console.log(err); 7 | } 8 | return true; 9 | }); 10 | }; 11 | 12 | module.exports = { saveToFile }; 13 | -------------------------------------------------------------------------------- /dashboard-app/client/src/App.test.mjs: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /lib/utils/rate-limiter.js: -------------------------------------------------------------------------------- 1 | const rateLimiter = (delay = 1500) => { 2 | /* The 1500 delay will guarantee the script will not exceed Github request 3 | limit of 1500 per hour. Only increase if you have a higher rate limit */ 4 | return new Promise((resolve) => setTimeout(() => resolve(true), delay)); 5 | }; 6 | 7 | module.exports = { rateLimiter }; 8 | -------------------------------------------------------------------------------- /lib/validation/valid-labels.js: -------------------------------------------------------------------------------- 1 | const validLabels = { 2 | arabic: 'language: Arabic', 3 | chinese: 'language: Chinese', 4 | english: 'language: English', 5 | portuguese: 'language: Portuguese', 6 | russian: 'language: Russian', 7 | spanish: 'language: Spanish', 8 | curriculum: 'scope: curriculum', 9 | docs: 'scope: docs' 10 | }; 11 | 12 | module.exports = { validLabels }; 13 | -------------------------------------------------------------------------------- /dashboard-app/server/routes/info.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { INFO } = require('../models'); 3 | const { reqLimiter } = require('../req-limiter'); 4 | 5 | router.get('/', reqLimiter, async (request, response) => { 6 | const [{ lastUpdate, numPRs, prRange }] = await INFO.find({}); 7 | response.json({ ok: true, lastUpdate, numPRs, prRange }); 8 | }); 9 | 10 | module.exports = router; 11 | -------------------------------------------------------------------------------- /lib/utils/index.js: -------------------------------------------------------------------------------- 1 | const { rateLimiter } = require('./rate-limiter'); 2 | const { savePrData } = require('./save-pr-data'); 3 | const { saveToFile } = require('./save-to-file'); 4 | const { openJSONFile } = require('./open-json-file'); 5 | const { ProcessingLog } = require('./processing-log'); 6 | 7 | module.exports = { 8 | rateLimiter, 9 | savePrData, 10 | saveToFile, 11 | openJSONFile, 12 | ProcessingLog 13 | }; 14 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/SearchOption.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SearchOption = ({ children, value, selectedOption, onOptionChange }) => ( 4 | 14 | ); 15 | 16 | export default SearchOption; 17 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/Input.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Container = styled.input` 5 | margin-bottom: 10px; 6 | `; 7 | 8 | const Input = React.forwardRef((props, ref) => ( 9 | 16 | )); 17 | 18 | export default Input; 19 | -------------------------------------------------------------------------------- /dashboard-app/client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import './index.css'; 5 | import * as serviceWorker from './serviceWorker'; 6 | import theme from './theme'; 7 | 8 | import App from './App'; 9 | import { ThemeProvider } from 'styled-components'; 10 | 11 | ReactDOM.render( 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ); 17 | 18 | serviceWorker.unregister(); 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tools 2 | 3 | Tools to help maintain [freeCodeCamp.org](https://www.freecodecamp.org)'s Open Source Codebase on GitHub. Dashboard is available at 4 | 5 | ## How to use 6 | 7 | See [README.md](docs/README.md) for how to run locally. 8 | 9 | ### Credits 10 | 11 | Special thanks to these awesome people for their work: 12 | 13 | - [@RandellDawson](https://github.com/RandellDawson) 14 | - [@tbushman](https://github.com/tbushman) 15 | - [@honmanyau](https://github.com/honmanyau) 16 | -------------------------------------------------------------------------------- /dashboard-app/client/src/constants/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | let API_HOST = 4 | process.env.REACT_APP_HOST === 'local' ? 'http://localhost:3001' : ''; 5 | 6 | const ENDPOINT_INFO = API_HOST + '/info'; 7 | const ENDPOINT_PARETO = API_HOST + '/pareto'; 8 | const ENDPOINT_ALL_REPOS = API_HOST + '/all-repos'; 9 | const ENDPOINT_PR = API_HOST + '/pr'; 10 | const ENDPOINT_SEARCH = API_HOST + '/search'; 11 | export { 12 | API_HOST, 13 | ENDPOINT_INFO, 14 | ENDPOINT_PARETO, 15 | ENDPOINT_PR, 16 | ENDPOINT_SEARCH, 17 | ENDPOINT_ALL_REPOS 18 | }; 19 | -------------------------------------------------------------------------------- /dashboard-app/server/req-limiter.js: -------------------------------------------------------------------------------- 1 | const rateLimit = require('express-rate-limit'); 2 | 3 | const limitHandler = (req, res) => { 4 | res.status(429).json({ 5 | ok: false, 6 | rateLimitMessage: 7 | "You have accessed this app's pages too quickly. Please try again in 5 minutes." 8 | }); 9 | }; 10 | 11 | const rateLimitOptions = { 12 | windowMs: 5 * 60 * 1000, // 5 minutes 13 | max: 100, 14 | message: 'rate limit activated', 15 | handler: limitHandler, 16 | onLimitReached: limitHandler 17 | }; 18 | 19 | const reqLimiter = rateLimit(rateLimitOptions); 20 | 21 | module.exports = { reqLimiter }; 22 | -------------------------------------------------------------------------------- /dashboard-app/server/tools/get-prs.js: -------------------------------------------------------------------------------- 1 | const { Octokit } = require('@octokit/rest'); 2 | 3 | const { 4 | github: { owner, secret, freeCodeCampRepo } 5 | } = require('../../../lib/config'); 6 | 7 | const getPRs = async () => { 8 | const octokit = new Octokit({ auth: secret }); 9 | 10 | /* eslint-disable camelcase */ 11 | const methodProps = { 12 | owner, 13 | repo: freeCodeCampRepo, 14 | state: 'open', 15 | sort: 'created', 16 | direction: 'asc', 17 | per_page: 100 18 | }; 19 | 20 | const openPRs = await octokit.paginate(octokit.pulls.list, methodProps); 21 | return openPRs; 22 | }; 23 | 24 | module.exports = getPRs; 25 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/FilterOption.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Container = styled.label` 5 | @media (max-width: 600px) { 6 | display: block; 7 | } 8 | `; 9 | 10 | const FilterOption = ({ 11 | group, 12 | children, 13 | value, 14 | selectedOption, 15 | onOptionChange 16 | }) => { 17 | return ( 18 | 19 | 26 | {children} 27 | 28 | ); 29 | }; 30 | export default FilterOption; 31 | -------------------------------------------------------------------------------- /lib/utils/save-pr-data.js: -------------------------------------------------------------------------------- 1 | const formatDate = require('date-fns/format'); 2 | const path = require('path'); 3 | 4 | const { saveToFile } = require('./save-to-file'); 5 | 6 | const savePrData = (openPRs, firstPR, lastPR) => { 7 | const now = formatDate(new Date(), 'YYYY-MM-DDTHHmmss'); 8 | const filename = path.resolve( 9 | __dirname, 10 | `../../work-logs/data-for-openprs_${firstPR}-${lastPR}_${now}.json` 11 | ); 12 | console.log(`# of PRs Retrieved: ${openPRs.length}`); 13 | console.log(`PR Range: ${firstPR} - ${lastPR}`); 14 | saveToFile(filename, JSON.stringify(openPRs)); 15 | console.log(`Data saved in file: ${filename}`); 16 | }; 17 | 18 | module.exports = { savePrData }; 19 | -------------------------------------------------------------------------------- /dashboard-app/server/routes/all-repos.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { ALL_REPOS } = require('../models'); 3 | const { reqLimiter } = require('../req-limiter'); 4 | 5 | router.get('/', reqLimiter, async (request, response) => { 6 | let allRepos = await ALL_REPOS.find({}).then((data) => data); 7 | allRepos.sort((a, b) => a._id - b._id); 8 | allRepos = allRepos.reduce((allReposArr, aRepo) => { 9 | const { _id, prs } = aRepo; 10 | if (prs.length) { 11 | prs.sort((a, b) => a._id - b._id); 12 | return allReposArr.concat({ _id, prs }); 13 | } 14 | return allRepos; 15 | }, []); 16 | 17 | response.json({ ok: true, allRepos }); 18 | }); 19 | 20 | module.exports = router; 21 | -------------------------------------------------------------------------------- /lib/pr-tasks/add-labels.js: -------------------------------------------------------------------------------- 1 | const { Octokit } = require('@octokit/rest'); 2 | const { 3 | github: { owner, secret, freeCodeCampRepo } 4 | } = require('../config'); 5 | 6 | const octokit = new Octokit({ auth: secret }); 7 | 8 | const addLabels = (number, labels, log) => { 9 | octokit.issues 10 | .addLabels({ owner, repo: freeCodeCampRepo, number, labels }) 11 | .then(() => { 12 | console.log(`PR #${number} added ${JSON.stringify(labels)}\n`); 13 | }) 14 | .catch((err) => { 15 | console.log( 16 | `PR #${number} had an error when trying to labels: ${JSON.stringify( 17 | labels 18 | )}\n` 19 | ); 20 | console.log(err); 21 | log.finish(); 22 | }); 23 | }; 24 | 25 | module.exports = { addLabels }; 26 | -------------------------------------------------------------------------------- /dashboard-app/server/tools/getFilenames.js: -------------------------------------------------------------------------------- 1 | const { Octokit } = require('@octokit/rest'); 2 | 3 | const { 4 | github: { owner, secret, freeCodeCampRepo } 5 | } = require('../../../lib/config'); 6 | 7 | const octokit = new Octokit({ auth: secret }); 8 | 9 | const getFiles = async (number) => { 10 | /* eslint-disable camelcase */ 11 | const methodProps = { 12 | owner, 13 | repo: freeCodeCampRepo, 14 | pull_number: number, 15 | per_page: 100 16 | }; 17 | 18 | const files = await octokit.paginate(octokit.pulls.listFiles, methodProps); 19 | return files; 20 | }; 21 | 22 | const getFilenames = async (number) => { 23 | const files = await getFiles(number); 24 | return files.map(({ filename }) => filename); 25 | }; 26 | 27 | module.exports = getFilenames; 28 | -------------------------------------------------------------------------------- /lib/pr-tasks/add-comment.js: -------------------------------------------------------------------------------- 1 | const { Octokit } = require('@octokit/rest'); 2 | 3 | const { 4 | github: { owner, secret, freeCodeCampRepo } 5 | } = require('../config'); 6 | 7 | const octokit = new Octokit({ auth: secret }); 8 | 9 | const addComment = async (number, comment) => { 10 | const result = await octokit.issues 11 | .createComment({ 12 | owner, 13 | repo: freeCodeCampRepo, 14 | number, 15 | body: comment 16 | }) 17 | .catch((err) => { 18 | console.log(`PR #${number} had an error when trying to add a comment\n`); 19 | console.log(err); 20 | }); 21 | 22 | if (result) { 23 | console.log(`PR #${number} successfully added a comment\n`); 24 | } 25 | return result; 26 | }; 27 | 28 | module.exports = { addComment }; 29 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Container = styled.div` 5 | margin-top: 5px; 6 | text-align: center; 7 | `; 8 | 9 | const Info = styled.div` 10 | font-size: 14px; 11 | padding: 2px; 12 | `; 13 | 14 | const Footer = (props) => { 15 | const localTime = (lastUpdate) => { 16 | const newTime = new Date(lastUpdate); 17 | return newTime.toLocaleString(); 18 | }; 19 | 20 | const { 21 | footerInfo: { numPRs, prRange, lastUpdate } 22 | } = props; 23 | return ( 24 | lastUpdate && ( 25 | 26 | Last Update: {localTime(lastUpdate)} 27 | 28 | # of open PRs: {numPRs} ({prRange}) 29 | 30 | 31 | ) 32 | ); 33 | }; 34 | 35 | export default Footer; 36 | -------------------------------------------------------------------------------- /one-off-scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@freecodecamp/contributor-tools-one-off-scripts", 3 | "version": "0.0.1", 4 | "description": "The freeCodeCamp.org open-source codebase and curriculum", 5 | "license": "BSD-3-Clause", 6 | "private": true, 7 | "engines": { 8 | "node": ">=16", 9 | "npm": ">=8" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/freeCodeCamp/freeCodeCamp/issues" 17 | }, 18 | "homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme", 19 | "author": "freeCodeCamp ", 20 | "main": "none", 21 | "scripts": { 22 | "test": "echo \"Error: no test specified\" && exit 1" 23 | }, 24 | "dependencies": { 25 | "@octokit/rest": "18.5.2", 26 | "cli-progress": "3.6.1", 27 | "dedent": "0.7.0", 28 | "path": "0.12.7" 29 | }, 30 | "devDependencies": {} 31 | } 32 | -------------------------------------------------------------------------------- /dashboard-app/server/models/index.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const pr = new mongoose.Schema({ 4 | _id: Number, 5 | updatedAt: String, 6 | username: String, 7 | title: String, 8 | filenames: [String] 9 | }); 10 | 11 | const info = new mongoose.Schema({ 12 | lastUpdate: Date, 13 | numPRs: Number, 14 | prRange: String 15 | }); 16 | 17 | const allRepos = new mongoose.Schema({ 18 | _id: String, 19 | prs: [ 20 | { 21 | _id: Number, 22 | title: String, 23 | username: String, 24 | prLink: String 25 | } 26 | ] 27 | }); 28 | 29 | const dbCollections = { 30 | pr: 'openprs', 31 | info: 'info', 32 | boilerplate: 'boilerplate', 33 | otherPrs: 'otherPrs' 34 | }; 35 | 36 | const PR = mongoose.model('PR', pr, dbCollections['pr']); 37 | const INFO = mongoose.model('INFO', info, dbCollections['info']); 38 | const ALL_REPOS = mongoose.model( 39 | 'ALL_REPOS', 40 | allRepos, 41 | dbCollections['allRepos'] 42 | ); 43 | module.exports = { PR, INFO, ALL_REPOS, dbCollections }; 44 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/ListItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Container = styled.div` 5 | display: flex; 6 | justify-content: space-between; 7 | flex-direction: row; 8 | overflow: hidden; 9 | @media (max-width: 600px) { 10 | margin-top: 1em; 11 | flex-direction: column; 12 | } 13 | `; 14 | 15 | const prNumStyle = { flex: 1 }; 16 | const usernameStyle = { flex: 1 }; 17 | const titleStyle = { flex: 3 }; 18 | 19 | const ListItem = ({ number, username, prTitle: title, prLink }) => { 20 | const prUrl = prLink 21 | ? prLink 22 | : `https://github.com/freeCodeCamp/freeCodeCamp/pull/${number}`; 23 | return ( 24 | 25 | 31 | #{number} 32 | 33 | {username} 34 | {title} 35 | 36 | ); 37 | }; 38 | 39 | export default ListItem; 40 | -------------------------------------------------------------------------------- /lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@freecodecamp/contributor-tools-lib", 3 | "version": "0.0.1", 4 | "description": "The freeCodeCamp.org open-source codebase and curriculum", 5 | "license": "BSD-3-Clause", 6 | "private": true, 7 | "engines": { 8 | "node": ">=16", 9 | "npm": ">=8" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/freeCodeCamp/freeCodeCamp/issues" 17 | }, 18 | "homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme", 19 | "author": "freeCodeCamp ", 20 | "main": "none", 21 | "scripts": { 22 | "test": "echo \"Error: no test specified\" && exit 1" 23 | }, 24 | "dependencies": { 25 | "@octokit/rest": "18.5.2", 26 | "cli-progress": "3.6.1", 27 | "date-fns": "1.30.1", 28 | "dedent": "0.7.0", 29 | "dotenv": "6.2.0", 30 | "form-data": "2.5.1", 31 | "gray-matter": "4.0.2", 32 | "lodash": "^4.17.21", 33 | "path": "0.12.7", 34 | "readdirp-walk": "1.7.0", 35 | "travis-ci": "2.2.0", 36 | "util": "0.11.1" 37 | }, 38 | "devDependencies": {} 39 | } 40 | -------------------------------------------------------------------------------- /dashboard-app/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@freecodecamp/dashboard-client", 3 | "version": "0.0.1", 4 | "description": "The freeCodeCamp.org open-source codebase and curriculum", 5 | "license": "BSD-3-Clause", 6 | "private": true, 7 | "engines": { 8 | "node": ">=16", 9 | "npm": ">=8" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/freeCodeCamp/freeCodeCamp/issues" 17 | }, 18 | "author": "freeCodeCamp ", 19 | "main": "none", 20 | "scripts": { 21 | "build": "cross-env SKIP_PREFLIGHT_CHECK=true react-scripts build", 22 | "dev": "develop", 23 | "develop": "cross-env REACT_APP_HOST=local SKIP_PREFLIGHT_CHECK=true react-scripts start", 24 | "eject": "react-scripts eject", 25 | "test": "SKIP_PREFLIGHT_CHECK=true react-scripts test" 26 | }, 27 | "browserslist": [ 28 | ">0.2%", 29 | "not dead", 30 | "not ie <= 11", 31 | "not op_mini all" 32 | ], 33 | "dependencies": { 34 | "react": "16.14.0", 35 | "react-dom": "16.14.0", 36 | "react-scripts": "2.1.8", 37 | "styled-components": "4.4.1" 38 | }, 39 | "devDependencies": { 40 | "cross-env": "5.2.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /dashboard-app/server/routes/pareto.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { PR } = require('../models'); 3 | const { reqLimiter } = require('../req-limiter'); 4 | 5 | const createPareto = (reportObj) => 6 | Object.keys(reportObj) 7 | .reduce((arr, filename) => { 8 | const { count, prs } = reportObj[filename]; 9 | if (count > 1) { 10 | arr.push({ filename, count, prs }); 11 | } 12 | return arr; 13 | }, []) 14 | .sort((a, b) => b.count - a.count); 15 | 16 | router.get('/', reqLimiter, async (request, response) => { 17 | const prs = await PR.find({}).then((data) => data); 18 | prs.sort((a, b) => a._id - b._id); 19 | const reportObj = prs.reduce((obj, pr) => { 20 | const { _id: number, filenames, username, title } = pr; 21 | filenames.forEach((filename) => { 22 | if (obj[filename]) { 23 | const { count, prs } = obj[filename]; 24 | obj[filename] = { 25 | count: count + 1, 26 | prs: prs.concat({ number, username, title }) 27 | }; 28 | } else { 29 | obj[filename] = { count: 1, prs: [{ number, username, title }] }; 30 | } 31 | }); 32 | return obj; 33 | }, {}); 34 | 35 | response.json({ ok: true, pareto: createPareto(reportObj) }); 36 | }); 37 | 38 | module.exports = router; 39 | -------------------------------------------------------------------------------- /dashboard-app/client/src/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Lato'; 3 | src: url(./fonts/Lato-Regular.ttf) format('truetype'); 4 | font-display: fallback; 5 | } 6 | 7 | @font-face { 8 | font-family: 'Lato Light'; 9 | src: url(./fonts/Lato-Light.ttf) format('truetype'); 10 | font-display: fallback; 11 | } 12 | 13 | @font-face { 14 | font-family: 'Lato Bold'; 15 | src: url(./fonts/Lato-Bold.ttf) format('truetype'); 16 | font-display: fallback; 17 | } 18 | 19 | * { 20 | box-sizing: border-box; 21 | } 22 | 23 | body { 24 | margin: 0; 25 | padding: 0; 26 | font-family: Lato, sans-serif; 27 | -webkit-font-smoothing: antialiased; 28 | -moz-osx-font-smoothing: grayscale; 29 | } 30 | 31 | .app-menu { 32 | margin: 0; 33 | padding: 13px 15px; 34 | margin-right: 15px; 35 | 36 | display: flex; 37 | justify-content: space-between; 38 | align-items: center; 39 | list-style: none; 40 | } 41 | 42 | .app-menu li a { 43 | padding: 13px 15px; 44 | color: white; 45 | font-size: 20px; 46 | font-weight: 700; 47 | } 48 | 49 | .app-menu li a:hover { 50 | color: #0a0a23; 51 | background: white; 52 | } 53 | 54 | a { 55 | text-decoration: none; 56 | color: #0a0a23; 57 | } 58 | 59 | a:visited { 60 | text-decoration: none; 61 | color: purple; 62 | } 63 | 64 | code { 65 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 66 | monospace; 67 | } 68 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | const path = require('path'); 3 | require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); 4 | 5 | // define validation for all the env vars 6 | const envVarsSchema = Joi.object({ 7 | NODE_ENV: Joi.string() 8 | .allow(['development', 'production', 'test', 'provision']) 9 | .default('development'), 10 | PORT: Joi.number().default(3001), 11 | MONGO_HOST: Joi.string().required().description('Mongo DB host url'), 12 | GITHUB_USERNAME: Joi.string().required(), 13 | GITHUB_ACCESS_TOKEN: Joi.string().required(), 14 | REPOSITORY_OWNER: Joi.string().required(), 15 | REPOSITORY: Joi.string().required(), 16 | DEFAULT_BASE: Joi.string().required(), 17 | PRODUCTION_RUN: Joi.boolean().default(false) 18 | }) 19 | .unknown() 20 | .required(); 21 | 22 | const { error, value: envVars } = Joi.validate(process.env, envVarsSchema); 23 | if (error) { 24 | throw new Error(`Config validation error: ${error.message}`); 25 | } 26 | 27 | const config = { 28 | env: envVars.NODE_ENV, 29 | mongo: { 30 | host: envVars.MONGO_HOST 31 | }, 32 | github: { 33 | id: envVars.GITHUB_USERNAME, 34 | secret: envVars.GITHUB_ACCESS_TOKEN, 35 | owner: envVars.REPOSITORY_OWNER, 36 | freeCodeCampRepo: envVars.REPOSITORY, 37 | defaultBase: envVars.DEFAULT_BASE 38 | }, 39 | oneoff: { 40 | productionRun: envVars.PRODUCTION_RUN 41 | } 42 | }; 43 | 44 | module.exports = config; 45 | -------------------------------------------------------------------------------- /lib/get-prs/pr-stats.js: -------------------------------------------------------------------------------- 1 | const { Octokit } = require('@octokit/rest'); 2 | const { 3 | github: { owner, secret } 4 | } = require('../config'); 5 | 6 | const octokit = new Octokit({ auth: secret }); 7 | 8 | const getCount = async (repo, base) => { 9 | const baseStr = base ? `+base:${base}` : ''; 10 | /* eslint-disable camelcase */ 11 | const { 12 | data: { total_count: count } 13 | } = await octokit.search 14 | .issues({ 15 | q: `repo:${owner}/${repo}+is:open+type:pr${baseStr}`, 16 | sort: 'created', 17 | order: 'asc', 18 | page: 1, 19 | per_page: 1 20 | }) 21 | .catch((err) => console.log(err)); 22 | return count; 23 | }; 24 | 25 | const getRange = async (repo, base) => { 26 | let methodProps = { 27 | owner, 28 | repo, 29 | state: 'open', 30 | sort: 'created', 31 | page: 1, 32 | per_page: 1 33 | }; 34 | if (base) { 35 | methodProps = { ...methodProps, base }; 36 | } 37 | let response = await octokit.pulls.list({ 38 | direction: 'asc', 39 | ...methodProps 40 | }); 41 | // In the case there are no open PRs for repo 42 | if (!response.data.length) { 43 | return [null, null]; 44 | } 45 | const firstPR = response.data[0].number; 46 | response = await octokit.pulls.list({ 47 | direction: 'desc', 48 | ...methodProps 49 | }); 50 | const lastPR = response.data[0].number; 51 | return [firstPR, lastPR]; 52 | }; 53 | 54 | module.exports = { getCount, getRange }; 55 | -------------------------------------------------------------------------------- /dashboard-app/server/tools/get-repos.js: -------------------------------------------------------------------------------- 1 | const { Octokit } = require('@octokit/rest'); 2 | 3 | const { 4 | github: { owner, secret } 5 | } = require('../../../lib/config'); 6 | 7 | const getRepos = async () => { 8 | const octokit = new Octokit({ auth: secret }); 9 | 10 | /* eslint-disable camelcase */ 11 | const methodProps = { 12 | org: owner, 13 | sort: 'full_name', 14 | direction: 'asc', 15 | per_page: 100 16 | }; 17 | 18 | const repos = await octokit.paginate(octokit.repos.listForOrg, methodProps); 19 | const otherRepos = repos 20 | .filter((repo) => !repo.archived && repo.name !== owner) 21 | .map((repo) => repo.name); 22 | 23 | const reposToAdd = []; 24 | for (let repo of otherRepos) { 25 | const methodProps = { 26 | owner, 27 | repo, 28 | state: 'open', 29 | sort: 'created', 30 | direction: 'asc', 31 | page: 1, 32 | per_page: 100 33 | }; 34 | 35 | const openPRs = await octokit.paginate(octokit.pulls.list, methodProps); 36 | 37 | if (openPRs.length) { 38 | const prsToAdd = []; 39 | for (let pr of openPRs) { 40 | const { 41 | number, 42 | title, 43 | user: { login: username }, 44 | html_url: prLink 45 | } = pr; 46 | prsToAdd.push({ _id: number, title, username, prLink }); 47 | } 48 | reposToAdd.push({ _id: repo, prs: prsToAdd }); 49 | } 50 | } 51 | return reposToAdd; 52 | }; 53 | 54 | module.exports = getRepos; 55 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/PrResults.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import ListItem from './ListItem'; 5 | import FullWidthDiv from './FullWidthDiv'; 6 | import Result from './Result'; 7 | 8 | const List = styled.ul` 9 | margin: 3px; 10 | `; 11 | 12 | const PrResults = ({ searchValue, results, rateLimitMessage }) => { 13 | const elements = results.map((result, idx) => { 14 | const { number, filenames, username, title } = result; 15 | const files = filenames.map((filename, index) => { 16 | const fileOnMain = `https://github.com/freeCodeCamp/freeCodeCamp/blob/main/${filename}`; 17 | return ( 18 |
  • 19 | {filename}{' '} 20 | 21 | (File on Main) 22 | 23 |
  • 24 | ); 25 | }); 26 | 27 | return ( 28 | 29 | 30 | {files} 31 | 32 | ); 33 | }); 34 | 35 | const showResults = () => { 36 | if (!rateLimitMessage) { 37 | return ( 38 | (results.length ?

    Results for PR# {searchValue}

    : null) && 39 | elements 40 | ); 41 | } else { 42 | return rateLimitMessage; 43 | } 44 | }; 45 | 46 | return {showResults()}; 47 | }; 48 | 49 | export default PrResults; 50 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/FilenameResults.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import ListItem from './ListItem'; 5 | import FullWidthDiv from './FullWidthDiv'; 6 | import Result from './Result'; 7 | 8 | const List = styled.div` 9 | margin: 5px; 10 | display: flex; 11 | flex-direction: column; 12 | `; 13 | 14 | const filenameTitle = { fontWeight: '600' }; 15 | 16 | const FilenameResults = ({ searchValue, results, rateLimitMessage }) => { 17 | const elements = results.map((result) => { 18 | const { filename, prs: prObjects } = result; 19 | const prs = prObjects.map(({ number, username, title }, index) => { 20 | return ; 21 | }); 22 | 23 | const fileOnMain = `https://github.com/freeCodeCamp/freeCodeCamp/blob/main/${filename}`; 24 | return ( 25 | 26 | {filename}{' '} 27 | 28 | (File on Main) 29 | 30 | {prs} 31 | 32 | ); 33 | }); 34 | const showResults = () => { 35 | if (!rateLimitMessage) { 36 | return ( 37 | (results.length ?

    Results for: {searchValue}

    : null) && 38 | elements 39 | ); 40 | } else { 41 | return rateLimitMessage; 42 | } 43 | }; 44 | 45 | return {showResults()}; 46 | }; 47 | 48 | export default FilenameResults; 49 | -------------------------------------------------------------------------------- /dashboard-app/server/routes/pr.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { PR } = require('../models'); 3 | const { reqLimiter } = require('../req-limiter'); 4 | 5 | router.get('/:number', reqLimiter, async (request, response) => { 6 | const prs = await PR.find({}).then((data) => data); 7 | prs.sort((a, b) => a._id - b._id); 8 | const indices = prs.reduce((obj, { _id }, index) => { 9 | obj[_id] = index; 10 | return obj; 11 | }, {}); 12 | const { number: refNumber } = request.params; 13 | const index = indices[refNumber]; 14 | 15 | if (!index && index !== 0) { 16 | response.json({ 17 | ok: true, 18 | message: `Unable to find an open PR with #${refNumber}.`, 19 | results: [] 20 | }); 21 | return; 22 | } 23 | 24 | const pr = prs[index]; 25 | const results = []; 26 | const { filenames: refFilenames } = pr; 27 | 28 | prs.forEach(({ _id: number, filenames, username, title }) => { 29 | if (number !== +refNumber) { 30 | const matchedFilenames = filenames.filter((filename) => { 31 | return refFilenames.includes(filename); 32 | }); 33 | if (matchedFilenames.length) { 34 | results.push({ number, filenames: matchedFilenames, username, title }); 35 | } 36 | } 37 | }); 38 | 39 | if (!results.length) { 40 | let msg = `No other open PRs with matching filenames of PR #${refNumber}`; 41 | response.json({ ok: true, message: msg, results: [] }); 42 | return; 43 | } 44 | response.json({ ok: true, results }); 45 | }); 46 | 47 | module.exports = router; 48 | -------------------------------------------------------------------------------- /one-off-scripts/close-open-specific-failures.js: -------------------------------------------------------------------------------- 1 | const { 2 | github: { freeCodeCampRepo, defaultBase }, 3 | oneoff: { productionRun } 4 | } = require('../lib/config'); 5 | 6 | const { closeOpen } = require('../lib/pr-tasks'); 7 | const { openJSONFile, ProcessingLog, rateLimiter } = require('../lib/utils'); 8 | 9 | const log = new ProcessingLog('prs-closed-reopened'); 10 | 11 | log.start(); 12 | const getUserInput = async () => { 13 | let filename = process.argv[2]; 14 | 15 | if (!filename) { 16 | throw 'Specify a file with PRs which needed to be closed and reopened.'; 17 | } 18 | 19 | let fileObj = openJSONFile(filename); 20 | let { prs } = fileObj; 21 | if (!prs.length) { 22 | throw 'Either no PRs found in file or there or an error occurred.'; 23 | } 24 | return { prs }; 25 | }; 26 | 27 | (async () => { 28 | const { prs } = await getUserInput(freeCodeCampRepo, defaultBase); 29 | return prs; 30 | })() 31 | .then(async (prs) => { 32 | for (let { number, errorDesc } of prs) { 33 | if (errorDesc !== 'unknown error') { 34 | log.add(number, { number, closedOpened: true, errorDesc }); 35 | if (productionRun) { 36 | await closeOpen(number); 37 | await rateLimiter(90000); 38 | } 39 | } else { 40 | log.add(number, { number, closedOpened: false, errorDesc }); 41 | } 42 | } 43 | }) 44 | .then(() => { 45 | log.finish(); 46 | console.log('closing/reopening of PRs complete'); 47 | }) 48 | .catch((err) => { 49 | log.finish(); 50 | console.log(err); 51 | }); 52 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, freeCodeCamp.org 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /dashboard-app/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@freecodecamp/dashboard-server", 3 | "version": "0.0.1", 4 | "description": "The freeCodeCamp.org open-source codebase and curriculum", 5 | "license": "BSD-3-Clause", 6 | "private": true, 7 | "engines": { 8 | "node": ">=16", 9 | "npm": ">=8" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/freeCodeCamp/freeCodeCamp/issues" 17 | }, 18 | "homepage": "https://tools.freecodecamp.org", 19 | "author": "freeCodeCamp ", 20 | "main": "none", 21 | "scripts": { 22 | "dev": "develop", 23 | "develop": "nodemon", 24 | "lint": "standard --fix", 25 | "start": "node index.js", 26 | "test": "cross-env TEST_ENV=true jest --forceExit && standard", 27 | "test:watch": "jest --watch --notify --notifyMode=change --coverage" 28 | }, 29 | "jest": { 30 | "testEnvironment": "node" 31 | }, 32 | "nodemonConfig": { 33 | "exec": "npm start", 34 | "watch": [ 35 | ".env", 36 | "." 37 | ] 38 | }, 39 | "standard": { 40 | "env": [ 41 | "jest" 42 | ] 43 | }, 44 | "dependencies": { 45 | "@octokit/rest": "18.5.2", 46 | "body-parser": "1.19.0", 47 | "cross-env": "5.2.1", 48 | "express": "4.17.1", 49 | "express-rate-limit": "5.2.3", 50 | "mongoose": "5.11.7" 51 | }, 52 | "devDependencies": { 53 | "expect": "23.6.0", 54 | "jest": "22.4.4", 55 | "nodemon": "1.19.4", 56 | "smee-client": "1.2.2", 57 | "standard": "10.0.3" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/Tabs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Container = styled.div` 5 | display: flex; 6 | justify-content: center; 7 | height: 40px; 8 | margin: 20px; 9 | `; 10 | 11 | const Tab = styled.div` 12 | background: ${({ active, theme }) => (active ? theme.primary : 'white')}; 13 | color: ${({ active, theme }) => (active ? 'white' : theme.primary)}; 14 | font-size: 18px; 15 | padding: 5px; 16 | border: 2px solid ${({ theme }) => theme.primary}; 17 | border-left: none; 18 | width: 200px; 19 | text-align: center; 20 | 21 | &:hover { 22 | cursor: pointer; 23 | background: ${({ theme }) => theme.primary}; 24 | color: white; 25 | } 26 | 27 | &:first-child { 28 | border-left: 2px solid ${({ theme }) => theme.primary}; 29 | } 30 | @media (max-width: 600px) { 31 | width: auto; 32 | min-width: 100px; 33 | } 34 | `; 35 | 36 | const Tabs = ({ view, onViewChange }) => { 37 | return ( 38 | 39 | 40 | Search 41 | 42 | 43 | Pareto 44 | 45 | 50 | Boilerplate PRs 51 | 52 | 53 | Other Repos' PRs 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default Tabs; 60 | -------------------------------------------------------------------------------- /dashboard-app/server/routes/search.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const { PR } = require('../models'); 3 | const { reqLimiter } = require('../req-limiter'); 4 | 5 | router.get('/', reqLimiter, async (request, response) => { 6 | const prs = await PR.find({}).then((data) => data); 7 | prs.sort((a, b) => a._id - b._id); 8 | const indices = prs.reduce((obj, { _id }, index) => { 9 | obj[_id] = index; 10 | return obj; 11 | }, {}); 12 | const value = request.query.value; 13 | 14 | if (value) { 15 | const filesFound = {}; 16 | prs.forEach(({ _id: number, filenames, username, title }) => { 17 | filenames.forEach((filename) => { 18 | if (filename.toLowerCase().includes(value.toLowerCase())) { 19 | const fileCount = prs[indices[number]].filenames.length; 20 | const prObj = { number, fileCount, username, title }; 21 | 22 | if (filesFound.hasOwnProperty(filename)) { 23 | filesFound[filename].push(prObj); 24 | } else { 25 | filesFound[filename] = [prObj]; 26 | } 27 | } 28 | }); 29 | }); 30 | 31 | let results = Object.keys(filesFound) 32 | .map((filename) => ({ filename, prs: filesFound[filename] })) 33 | .sort((a, b) => { 34 | if (a.filename === b.filename) { 35 | return 0; 36 | } else { 37 | return a.filename < b.filename ? -1 : 1; 38 | } 39 | }); 40 | if (!results.length) { 41 | response.json({ ok: true, message: 'No matching results.', results: [] }); 42 | return; 43 | } 44 | response.json({ ok: true, results }); 45 | } 46 | }); 47 | 48 | module.exports = router; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@freecodecamp/contributor-tools", 3 | "version": "0.0.1", 4 | "description": "The freeCodeCamp.org open-source codebase and curriculum", 5 | "license": "BSD-3-Clause", 6 | "private": true, 7 | "engines": { 8 | "node": ">=16", 9 | "npm": ">=8" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/freeCodeCamp/freeCodeCamp/issues" 17 | }, 18 | "homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme", 19 | "author": "freeCodeCamp ", 20 | "main": "none", 21 | "scripts": { 22 | "bootstrap": "lerna bootstrap --ci", 23 | "build": "lerna run build", 24 | "develop": "run-p develop:*", 25 | "develop:client": "cd ./dashboard-app/client && npm run develop", 26 | "develop:server": "cd ./dashboard-app/server && npm run develop", 27 | "format": "prettier --write es5 ./**/*.{js,json} && npm run lint", 28 | "lint": "eslint ./**/*.js --fix", 29 | "postinstall": "npm run bootstrap", 30 | "start": "cd dashboard-app/server && npm start", 31 | "test": "run-p test:*", 32 | "test:client": "cd ./dashboard-app/client && npm run test" 33 | }, 34 | "nodemonConfig": { 35 | "exec": "npm start", 36 | "watch": [ 37 | ".env", 38 | "." 39 | ] 40 | }, 41 | "dependencies": { 42 | "ajv": "6.12.6", 43 | "ajv-keywords": "3.5.2" 44 | }, 45 | "devDependencies": { 46 | "dotenv": "8.2.0", 47 | "eslint": "5.16.0", 48 | "joi": "14.3.1", 49 | "lerna": "3.22.1", 50 | "mocha": "5.2.0", 51 | "nodemon": "1.19.4", 52 | "npm-run-all": "4.1.5", 53 | "prettier": "1.19.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/pr-tasks/labeler.js: -------------------------------------------------------------------------------- 1 | const config = require('../../lib/config'); 2 | const { validLabels } = require('../validation'); 3 | const { addLabels } = require('./add-labels'); 4 | const { rateLimiter } = require('../utils'); 5 | 6 | const labeler = async (number, prFiles, currentLabels) => { 7 | // holds potential labels to add based on file path 8 | const labelsToAdd = {}; 9 | const existingLabels = currentLabels.map(({ name }) => name); 10 | prFiles.forEach(({ filename }) => { 11 | /* remove '/challenges' from filename so 12 | language variable hold the language */ 13 | const filenameReplacement = filename.replace( 14 | /^curriculum\/challenges\//, 15 | 'curriculum/' 16 | ); 17 | const regex = 18 | /^(docs|curriculum)(?:\/)(english|arabic|chinese|portuguese|russian|spanish)?\/?/; 19 | // need an array to pass to labelsAdder 20 | const match = filenameReplacement.match(regex) || []; 21 | const articleType = match[1]; 22 | const language = match[2]; 23 | if (articleType && validLabels[articleType]) { 24 | labelsToAdd[validLabels[articleType]] = 1; 25 | } 26 | if (language && validLabels[language]) { 27 | labelsToAdd[validLabels[language]] = 1; 28 | } 29 | if (articleType === 'curriculum') { 30 | labelsToAdd['status: need to test locally'] = 1; 31 | } 32 | }); 33 | 34 | // only adds needed labels which are NOT currently on the PR 35 | const newLabels = Object.keys(labelsToAdd).filter((label) => { 36 | return !existingLabels.includes(label); 37 | }); 38 | if (newLabels.length) { 39 | if (config.oneoff.productionRun) { 40 | addLabels(number, newLabels); 41 | await rateLimiter(1000); 42 | } 43 | } 44 | return newLabels; 45 | }; 46 | 47 | module.exports = { labeler }; 48 | -------------------------------------------------------------------------------- /lib/pr-tasks/close-open.js: -------------------------------------------------------------------------------- 1 | const { addComment } = require('./add-comment'); 2 | const { rateLimiter } = require('../utils'); 3 | const { 4 | github: { owner, secret, freeCodeCampRepo, defaultBase } 5 | } = require('../config'); 6 | 7 | const { Octokit } = require('@octokit/rest'); 8 | 9 | const octokit = new Octokit({ auth: secret }); 10 | 11 | /* closes and reopens an open PR with applicable comment */ 12 | const closeOpen = async (number) => { 13 | await octokit.pulls 14 | .update({ 15 | owner, 16 | repo: freeCodeCampRepo, 17 | number, 18 | state: 'closed', 19 | base: defaultBase 20 | }) 21 | .then(async () => { 22 | await rateLimiter(5000); 23 | return octokit.pulls.update({ 24 | owner, 25 | repo: freeCodeCampRepo, 26 | number, 27 | state: 'open', 28 | base: defaultBase 29 | }); 30 | }) 31 | .then(async () => { 32 | await rateLimiter(1000); 33 | const msg = 'Closed/Reopened to resolve a specific Travis build failure.'; 34 | await addComment(number, msg); 35 | }) 36 | .catch(async (err) => { 37 | // Octokit stores message as a stringified object 38 | const { errorMg } = JSON.parse(err.message); 39 | if ( 40 | errorMg === 41 | 'state cannot be changed. The repository that submitted this pull request has been deleted.' 42 | ) { 43 | await rateLimiter(1000); 44 | await addComment( 45 | number, 46 | "This PR was closed because user's repo was deleted." 47 | ); 48 | console.log( 49 | `PR #${number} was closed because user's repo was deleted.` 50 | ); 51 | } else { 52 | throw err; 53 | } 54 | }); 55 | }; 56 | 57 | module.exports = { closeOpen }; 58 | -------------------------------------------------------------------------------- /one-off-scripts/add-language-labels-to-files.js: -------------------------------------------------------------------------------- 1 | /* 2 | This script was created to iterate over all open PRs to label. 3 | 4 | To run the script for a specific range, 5 | run `node sweeper.js range startingPrNumber endingPrNumber` 6 | */ 7 | 8 | const { 9 | github: { freeCodeCampRepo, defaultBase } 10 | } = require('../lib/config'); 11 | const { getPRs, getUserInput, getFiles } = require('../lib/get-prs'); 12 | const { ProcessingLog, rateLimiter } = require('../lib/utils'); 13 | const { labeler } = require('../lib/pr-tasks'); 14 | 15 | const log = new ProcessingLog('add-language-labels'); 16 | 17 | log.start(); 18 | console.log('Curriculum File language labeler started...'); 19 | (async () => { 20 | const { totalPRs, firstPR, lastPR } = await getUserInput( 21 | freeCodeCampRepo, 22 | defaultBase 23 | ); 24 | const prPropsToGet = ['number', 'labels', 'user']; 25 | const { openPRs } = await getPRs( 26 | freeCodeCampRepo, 27 | defaultBase, 28 | totalPRs, 29 | firstPR, 30 | lastPR, 31 | prPropsToGet 32 | ); 33 | let count = 0; 34 | if (openPRs.length) { 35 | console.log('Processing PRs...'); 36 | for (let i = 0; i < openPRs.length; i++) { 37 | let { number, labels: currentLabels } = openPRs[i]; 38 | 39 | const prFiles = await getFiles(freeCodeCampRepo, number); 40 | count++; 41 | 42 | const labelsAdded = await labeler(number, prFiles, currentLabels); 43 | const labelLogVal = labelsAdded.length ? labelsAdded : 'none added'; 44 | 45 | log.add(number, { number, labels: labelLogVal }); 46 | if (count > 4000) { 47 | await rateLimiter(2350); 48 | } 49 | } 50 | } 51 | })() 52 | .then(() => { 53 | log.finish(); 54 | console.log('Labeler complete'); 55 | }) 56 | .catch((err) => { 57 | log.finish(); 58 | console.log(err); 59 | }); 60 | -------------------------------------------------------------------------------- /one-off-scripts/comments-and-labels-summary.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is a one-off script that was was used to summarize the results of a 3 | test_sweeper json log file after sweeper.js was run on a particular set of data. 4 | It generates a text file referencing only PRs with any comments/labels 5 | which would have beeen added (test) based on data stored in the 6 | specific JSON log file. You must run sweeper with environment variable 7 | PRODUCTION_RUN set to false, to get the test version. Technically, you 8 | could also run this on a production_sweeper json log file, if you wanted to see 9 | if the sweeper commented or labeled any PRs during its run. 10 | */ 11 | 12 | const { saveToFile, openJSONFile } = require('../lib/utils'); 13 | const path = require('path'); 14 | const dedent = require('dedent'); 15 | 16 | const specificLogFile = path.resolve( 17 | __dirname, 18 | '../work-logs/test_add-language-labels_26001-29000_2019-01-14T215420.json' 19 | ); 20 | 21 | (() => { 22 | let fileObj = openJSONFile(specificLogFile); 23 | let { prs } = fileObj; 24 | 25 | let count = 0; 26 | let prsWithComments = prs.reduce((text, { number, comment, labels }) => { 27 | if ((comment && comment !== 'none') || labels !== 'none added') { 28 | text += dedent` 29 | 30 | PR #${number} 31 | Comment: ${comment} 32 | 33 | Labels: ${JSON.stringify(labels)} 34 | 35 | *************************\n 36 | 37 | `; 38 | count++; 39 | } 40 | return text; 41 | }, ''); 42 | 43 | prsWithComments = dedent` 44 | # of PRs with comments or labels added: ${count} 45 | 46 | ************************* 47 | ${prsWithComments} 48 | `; 49 | 50 | saveToFile( 51 | path.resolve(__dirname, '../work-logs/guideErrorComments.txt'), 52 | prsWithComments 53 | ); 54 | console.log('guideErrorComments.txt created'); 55 | })(); 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #----- 2 | # Node 3 | #----- 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # parcel-bundler cache (https://parceljs.org/) 65 | .cache 66 | 67 | # next.js build output 68 | .next 69 | 70 | # nuxt.js build output 71 | .nuxt 72 | 73 | # vuepress build output 74 | .vuepress/dist 75 | 76 | # Serverless directories 77 | .serverless 78 | 79 | # FuseBox cache 80 | .fusebox/ 81 | 82 | #----------------- 83 | # Create React App 84 | #----------------- 85 | 86 | # dependencies 87 | /node_modules 88 | /.pnp 89 | .pnp.js 90 | 91 | # testing 92 | /coverage 93 | 94 | # production 95 | build 96 | 97 | # ------------ 98 | # Custom Files 99 | # ------------ 100 | 101 | work-logs 102 | # work-logs 103 | # 104 | 105 | # ------------ 106 | # Probot Files 107 | # ------------ 108 | 109 | *.pem 110 | *.vscode 111 | -------------------------------------------------------------------------------- /one-off-scripts/sweeper.js: -------------------------------------------------------------------------------- 1 | /* 2 | This script was originally created to iterate over all open PRs to label and 3 | comment on specific PR errors (i.e. guide related filenmame syntax and 4 | frontmatter). 5 | 6 | Since the first run which covered over 10,000+ PRs, it is curently ran every 7 | couple of days for just the most recent PRs. 8 | 9 | To run the script for a specific range, 10 | run `node sweeper.js range startingPrNumber endingPrNumber` 11 | */ 12 | 13 | const { 14 | github: { freeCodeCampRepo, defaultBase } 15 | } = require('../lib/config'); 16 | const { getPRs, getUserInput, getFiles } = require('../lib/get-prs'); 17 | const { ProcessingLog, rateLimiter } = require('../lib/utils'); 18 | const { labeler } = require('../lib/pr-tasks'); 19 | 20 | const log = new ProcessingLog('sweeper'); 21 | 22 | log.start(); 23 | console.log('Sweeper started...'); 24 | (async () => { 25 | const { totalPRs, firstPR, lastPR } = await getUserInput( 26 | freeCodeCampRepo, 27 | defaultBase 28 | ); 29 | const prPropsToGet = ['number', 'labels', 'user']; 30 | const { openPRs } = await getPRs( 31 | freeCodeCampRepo, 32 | defaultBase, 33 | totalPRs, 34 | firstPR, 35 | lastPR, 36 | prPropsToGet 37 | ); 38 | let count = 0; 39 | if (openPRs.length) { 40 | console.log('Processing PRs...'); 41 | for (let i = 0; i < openPRs.length; i++) { 42 | let { number, labels: currentLabels } = openPRs[i]; 43 | const prFiles = await getFiles(freeCodeCampRepo, number); 44 | count++; 45 | 46 | const labelsAdded = await labeler(number, prFiles, currentLabels); 47 | const labelLogVal = labelsAdded.length ? labelsAdded : 'none added'; 48 | 49 | log.add(number, { number, labels: labelLogVal }); 50 | if (count > 4000) { 51 | await rateLimiter(2350); 52 | } 53 | } 54 | } 55 | })() 56 | .then(() => { 57 | log.finish(); 58 | console.log('Sweeper complete'); 59 | }) 60 | .catch((err) => { 61 | log.finish(); 62 | console.log(err); 63 | }); 64 | -------------------------------------------------------------------------------- /dashboard-app/server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const mongoose = require('mongoose'); 3 | const path = require('path'); 4 | 5 | const app = express(); 6 | const config = require('../../lib/config'); 7 | const { pareto, pr, search, info, allRepos } = require('./routes'); 8 | const { reqLimiter } = require('./req-limiter'); 9 | 10 | // May need to uncomment the following to get rateLimit to work properly since we are using reverse-proxy 11 | // app.set('trust proxy', 1); 12 | 13 | app.use(express.static(path.join(__dirname, '../client/build'))); 14 | 15 | app.use((request, response, next) => { 16 | response.header('Access-Control-Allow-Origin', '*'); 17 | response.header( 18 | 'Access-Control-Allow-Headers', 19 | 'Origin, X-Requested-With, Content-Type, Accept' 20 | ); 21 | response.header('Access-Control-Allow-Methods', 'GET'); 22 | next(); 23 | }); 24 | 25 | const landingPage = path.join(__dirname, '../client/build/index.html'); 26 | app.get('/', reqLimiter, (req, res) => res.sendFile(landingPage)); 27 | 28 | app.use('/pr', pr); 29 | app.use('/search', search); 30 | app.use('/pareto', pareto); 31 | app.use('/info', info); 32 | app.use('/all-repos', allRepos); 33 | 34 | // 404 35 | app.use(function (req, res) { 36 | const message = 'Route' + req.url + ' Not found.'; 37 | console.log(message); 38 | return res.status(404).send({ message }); 39 | }); 40 | 41 | // 500 - Any server error 42 | app.use(function (err, req, res) { 43 | console.log('error: ' + err); 44 | return res.status(500).send({ error: err }); 45 | }); 46 | 47 | if (mongoose.connection.readyState === 0) { 48 | // connect to mongo db 49 | const mongoUri = config.mongo.host; 50 | 51 | const promise = mongoose.connect(mongoUri, { 52 | useNewUrlParser: true, 53 | useUnifiedTopology: true 54 | }); 55 | promise 56 | .then(() => { 57 | console.log('MongoDB is connected'); 58 | const portNum = process.env.PORT || 3000; 59 | app.listen(portNum, () => { 60 | console.log(`server listening on port ${portNum}`); 61 | }); 62 | }) 63 | .catch((err) => { 64 | console.log(err); 65 | console.log('MongoDB connection unsuccessful'); 66 | }); 67 | } 68 | 69 | module.exports = app; 70 | -------------------------------------------------------------------------------- /dashboard-app/client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 16 | 17 | 18 | 22 | 27 | 32 | 36 | 41 | 46 | 51 | 55 | freeCodeCamp Contributor Tools 56 | 57 | 58 | 61 |
    62 | 63 | 64 | -------------------------------------------------------------------------------- /one-off-scripts/add-test-locally-label.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is a one-off script to run on all open PRs to add the 3 | "status: need to test locally" label to any PR with an existing 4 | "scope: curriculum" label on it. 5 | */ 6 | 7 | const { 8 | github: { freeCodeCampRepo, defaultBase }, 9 | oneoff: { productionRun } 10 | } = require('../lib/config'); 11 | 12 | const { getPRs, getUserInput } = require('../lib/get-prs'); 13 | const { addLabels } = require('../lib/pr-tasks'); 14 | const { rateLimiter, ProcessingLog } = require('../lib/utils'); 15 | 16 | const log = new ProcessingLog('all-locally-tested-labels'); 17 | 18 | (async () => { 19 | const { totalPRs, firstPR, lastPR } = await getUserInput( 20 | freeCodeCampRepo, 21 | defaultBase 22 | ); 23 | const prPropsToGet = ['number', 'labels']; 24 | const { openPRs } = await getPRs( 25 | freeCodeCampRepo, 26 | defaultBase, 27 | totalPRs, 28 | firstPR, 29 | lastPR, 30 | prPropsToGet 31 | ); 32 | 33 | if (openPRs.length) { 34 | log.start(); 35 | console.log('Starting labeling process...'); 36 | for (let count = 0; count < openPRs.length; count++) { 37 | let { number, labels } = openPRs[count]; 38 | // holds potential labels to add based on file path 39 | const labelsToAdd = {}; 40 | const existingLabels = labels.map(({ name }) => name); 41 | if (existingLabels.includes('scope: curriculum')) { 42 | labelsToAdd['status: need to test locally'] = 1; 43 | } 44 | 45 | // only adds needed labels which are NOT currently on the PR 46 | const newLabels = Object.keys(labelsToAdd).filter((label) => { 47 | return !existingLabels.includes(label); 48 | }); 49 | 50 | if (newLabels.length) { 51 | log.add(number, { number, labels: newLabels }); 52 | if (productionRun) { 53 | addLabels(number, newLabels, log); 54 | await rateLimiter(); 55 | } 56 | } else { 57 | log.add(number, { number, labels: 'none added' }); 58 | } 59 | } 60 | } 61 | })() 62 | .then(() => { 63 | log.finish(); 64 | console.log('Successfully completed labeling'); 65 | }) 66 | .catch((err) => { 67 | log.finish(); 68 | console.log(err); 69 | }); 70 | -------------------------------------------------------------------------------- /lib/utils/processing-log.js: -------------------------------------------------------------------------------- 1 | const config = require('../../lib/config'); 2 | const formatDate = require('date-fns/format'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | 6 | const { saveToFile } = require('./save-to-file'); 7 | 8 | class ProcessingLog { 9 | constructor(script) { 10 | this._script = script; 11 | this._startTime = null; 12 | this._finishTime = null; 13 | this._elapsedTime = null; 14 | this._prs = []; 15 | this._prCount = null; 16 | this._logfile = path.resolve( 17 | __dirname, 18 | `../../work-logs/data-for_${this.getRunType()}_${this._script}.json` 19 | ); 20 | } 21 | 22 | getRunType() { 23 | return config.oneoff.productionRun ? 'production' : 'test'; 24 | } 25 | 26 | export() { 27 | const log = { 28 | startTime: this._startTime, 29 | finishTime: this._finishTime, 30 | elapsedTime: this._elapsedTime, 31 | prCount: this._prs.length, 32 | firstPR: this._firstPR, 33 | lastPR: this._lastPR, 34 | prs: this._prs 35 | }; 36 | saveToFile(this._logfile, JSON.stringify(log, null, 2)); 37 | } 38 | 39 | add(prNum, props) { 40 | this._prs.push(props); 41 | } 42 | 43 | getPrRange() { 44 | if (this._prs.length) { 45 | const first = this._prs[0].number; 46 | const last = this._prs[this._prs.length - 1].number; 47 | return [first, last]; 48 | } 49 | console.log('Current log file does not contain any PRs'); 50 | return [null, null]; 51 | } 52 | 53 | start() { 54 | this._startTime = new Date(); 55 | this.export(); 56 | } 57 | 58 | finish(logFileName = '') { 59 | this._finishTime = new Date(); 60 | const minutesElapsed = (this._finishTime - this._startTime) / 1000 / 60; 61 | this._elapsedTime = minutesElapsed.toFixed(2) + ' mins'; 62 | let [first, last] = this.getPrRange(); 63 | this._firstPR = first; 64 | this._lastPR = last; 65 | this.export(); 66 | this.changeFilename(logFileName); 67 | } 68 | 69 | changeFilename(logFileName) { 70 | const now = formatDate(new Date(), 'YYYY-MM-DDTHHmmss'); 71 | const prRange = `${this._firstPR}-${this._lastPR}`; 72 | let finalFilename = `${this.getRunType()}_${ 73 | this._script 74 | }_${prRange}_${now}.json`; 75 | let newFilename = path.resolve( 76 | __dirname, 77 | `../../work-logs/${finalFilename}` 78 | ); 79 | if (logFileName) { 80 | newFilename = logFileName; 81 | } 82 | fs.renameSync(this._logfile, newFilename); 83 | if (!fs.existsSync(newFilename)) { 84 | throw 'File rename unsuccessful.'; 85 | } 86 | this._logfile = newFilename; 87 | } 88 | } 89 | 90 | module.exports = { ProcessingLog }; 91 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/Repos.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import ListItem from './ListItem'; 5 | import FullWidthDiv from './FullWidthDiv'; 6 | import Result from './Result'; 7 | import { ENDPOINT_ALL_REPOS } from '../constants'; 8 | 9 | const List = styled.div` 10 | margin: 5px; 11 | display: flex; 12 | flex-direction: column; 13 | `; 14 | 15 | const detailsStyle = { padding: '3px' }; 16 | const filenameTitle = { fontWeight: '600' }; 17 | 18 | class Repos extends React.Component { 19 | constructor(props) { 20 | super(props); 21 | 22 | this.state = { 23 | data: [], 24 | rateLimitMessage: '' 25 | }; 26 | } 27 | 28 | componentDidMount() { 29 | fetch(ENDPOINT_ALL_REPOS) 30 | .then((response) => response.json()) 31 | .then(({ ok, rateLimitMessage, allRepos }) => { 32 | if (ok) { 33 | const repos = allRepos.filter(this.props.dataFilter); 34 | if (!repos.length) { 35 | repos.push({ 36 | repoName: 'No repos with open PRs', 37 | prs: [] 38 | }); 39 | } 40 | 41 | this.setState((prevState) => ({ 42 | data: repos 43 | })); 44 | } else if (rateLimitMessage) { 45 | this.setState((prevState) => ({ 46 | rateLimitMessage 47 | })); 48 | } 49 | }) 50 | .catch(() => { 51 | const repos = [{ repoName: 'No repos with open PRs', prs: [] }]; 52 | this.setState((prevState) => ({ data: repos })); 53 | }); 54 | } 55 | 56 | render() { 57 | const { data, rateLimitMessage } = this.state; 58 | 59 | const elements = rateLimitMessage 60 | ? rateLimitMessage 61 | : data.map((entry) => { 62 | const { _id: repoName, prs } = entry; 63 | const prsList = prs.map( 64 | ({ _id: number, username, title, prLink }) => { 65 | return ( 66 | 73 | ); 74 | } 75 | ); 76 | 77 | return ( 78 | 79 | {repoName} 80 |
    81 |
    82 | # of PRs: {prs.length} 83 | {prsList} 84 |
    85 |
    86 | ); 87 | }); 88 | 89 | return ( 90 | 91 | {rateLimitMessage 92 | ? rateLimitMessage 93 | : data.length 94 | ? elements 95 | : 'Report Loading...'} 96 | 97 | ); 98 | } 99 | } 100 | export default Repos; 101 | -------------------------------------------------------------------------------- /dashboard-app/server/tools/update-db.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const getRepos = require('./get-repos'); 3 | const getPRs = require('./get-prs'); 4 | const getFilenames = require('./getFilenames'); 5 | const { PR, INFO, ALL_REPOS } = require('../models'); 6 | 7 | const { mongo } = require('../../../lib/config'); 8 | 9 | // added to prevent deprecation warning when findOneAndUpdate is used 10 | mongoose.set('useFindAndModify', false); 11 | 12 | // connect to mongo db 13 | const mongoUri = mongo.host; 14 | const db = mongoose.connect(mongoUri, { 15 | useNewUrlParser: true, 16 | useUnifiedTopology: true 17 | }); 18 | 19 | const lastUpdate = new Date(); 20 | 21 | db.then(async () => { 22 | const reposToAdd = await getRepos(); 23 | await ALL_REPOS.deleteMany(); 24 | await ALL_REPOS.insertMany(reposToAdd); 25 | 26 | // update PRs for freeCodeCamp repo 27 | const oldPRs = await PR.find({}).then((data) => data); 28 | const oldIndices = oldPRs.reduce((obj, { _id }, index) => { 29 | obj[_id] = index; 30 | return obj; 31 | }, {}); 32 | 33 | const openPRs = await getPRs(); 34 | 35 | const newIndices = {}; 36 | for (let i = 0; i < openPRs.length; i++) { 37 | const { 38 | number, 39 | updated_at: updatedAt, 40 | title, 41 | user: { login: username } 42 | } = openPRs[i]; 43 | 44 | newIndices[number] = i; 45 | let oldPrData = oldPRs[oldIndices[number]]; 46 | const oldUpdatedAt = oldPrData ? oldPrData.updatedAt : null; 47 | if (!oldIndices.hasOwnProperty(number)) { 48 | // insert a new pr 49 | const filenames = await getFilenames(number); 50 | await PR.create({ _id: number, updatedAt, title, username, filenames }); 51 | console.log('added PR# ' + number); 52 | } else if (updatedAt > oldUpdatedAt) { 53 | // update an existing pr 54 | const filenames = await getFilenames(number); 55 | await PR.findOneAndUpdate( 56 | { _id: number }, 57 | { updatedAt, title, username, filenames } 58 | ); 59 | console.log('updated PR #' + number); 60 | } 61 | } 62 | for (let pr of oldPRs) { 63 | const { _id: number } = pr; 64 | if (!newIndices.hasOwnProperty(number)) { 65 | // delete pr because it is no longer open 66 | await PR.deleteOne({ _id: number }); 67 | console.log('deleted PR #' + number); 68 | } 69 | } 70 | }) 71 | .then(async () => { 72 | // update info collection 73 | const [{ firstPR, lastPR }] = await PR.aggregate([ 74 | { 75 | $group: { 76 | _id: null, 77 | firstPR: { $min: '$_id' }, 78 | lastPR: { $max: '$_id' } 79 | } 80 | } 81 | ]); 82 | const numPRs = await PR.countDocuments(); 83 | const info = { 84 | lastUpdate, 85 | numPRs, 86 | prRange: `${firstPR}-${lastPR}` 87 | }; 88 | await INFO.updateOne({}, info, { upsert: true }).catch((err) => { 89 | console.log(err); 90 | }); 91 | mongoose.connection.close(); 92 | }) 93 | .catch((err) => { 94 | mongoose.connection.close(); 95 | throw err; 96 | }); 97 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ## Local Setup 2 | 3 | - Follow the steps below to get this running on your local machine 4 | 5 | ### 1. Copy .env 6 | 7 | - Copy the `sample.env` file into `.env`. The command below will do that in the terminal if your CWD(current working directory) is the `contribute` folder. 8 | 9 | ```bash 10 | cp sample.env .env 11 | ``` 12 | 13 | - If you do not want to populate the database with the freeCodeCamp PR's you can [skip to step 5](#5-start-the-app-in-developemnt-mode) 14 | 15 | ### 2. Update .env 16 | 17 | - Use your GitHub username as the `GITHUB_USERNAME` variable of the `.env` file 18 | - Use your GitHub Personal Access Token as the `GITHUB_ACCESS_TOKEN` variable of the `.env` file 19 | 20 | ### 3. Run mongoDB 21 | 22 | - Make sure a mongoDB instance is running by running the command below in the terminal. 23 | 24 | ```bash 25 | mongod —dbpath=./database_folder 26 | ``` 27 | 28 | ### 4. Update the Database 29 | 30 | - Run the command below to populate your local database with PR’s from the freeCodeCamp repo. Note that you must have mongoDB running. 31 | 32 | ```bash 33 | node dashboard-app/server/tools/update-db.js 34 | ``` 35 | 36 | - This will take a while. If it stops running partway through, it's probably a timeout error. Run the command again and it should finish 37 | 38 | ### 5. Start the app in development mode 39 | 40 | - In a new terminal window or tab, run these three commands to start the program. Wait for one command to finish running before starting the next. 41 | 42 | ```bash 43 | npm ci 44 | npm run develop 45 | ``` 46 | 47 | ### 6. Start the app in production mode 48 | 49 | - In a new terminal window or tab, run these three commands to start the program. Wait for one command to finish running before starting the next. 50 | 51 | ```bash 52 | npm ci 53 | npm run build 54 | npm start 55 | ``` 56 | 57 | ## Caveats & Notes 58 | 59 | ### Local Ports when developing locally 60 | 61 | Using `npm run develop` will start both the api server and the Create React App(Dashboard) in development mode. The app server runs on port 3001 and the React app runs on port 3000. 62 | 63 | ### The one-off scripts will error out on actions performed by repository admins 64 | 65 | For example, if an admin removes a label from a Pull Request, the script can not add that label back. This is usually because the script is acting on behalf of a non-admin user with write access. 66 | This is usually the case with the use of access tokens for scripts. 67 | 68 | ### Setting up Cron jobs for Sweeper Scripts 69 | 70 | For updating dashboard data we use PM2 like so: 71 | 72 | ```bash 73 | pm2 start --no-autorestart dashboard-app/server/tools/update-db.js --cron "*/10 * * * *" 74 | ``` 75 | 76 | This will start the script in the "no restart" mode and re-run it every 10 minutes. 77 | A useful link to calculate a Cron expression: 78 | 79 | ### Starting the express server (via probot) 80 | 81 | ```bash 82 | pm2 start "npm start" --name "contribute-app" 83 | ``` 84 | 85 | **Note:** Start only one instance of this app, you can't have multiple probot apps running. Starting multiple instances will crash the app. 86 | -------------------------------------------------------------------------------- /one-off-scripts/find-failures.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is a one-off script to find all open PRs which have one of the 3 | console.error descriptions in the failuresToFind.json file. 4 | */ 5 | 6 | const { Octokit } = require('@octokit/rest'); 7 | const fetch = require('node-fetch'); 8 | 9 | const { 10 | github: { owner, secret, freeCodeCampRepo, defaultBase } 11 | } = require('../lib/config'); 12 | 13 | const octokit = new Octokit({ auth: secret }); 14 | 15 | const { getPRs, getUserInput } = require('../lib/get-prs'); 16 | const { savePrData, ProcessingLog } = require('../lib/utils'); 17 | 18 | const log = new ProcessingLog('find-failures-script'); 19 | 20 | const errorsToFind = [ 21 | { 22 | error: '', 23 | regex: '' 24 | } 25 | ]; 26 | 27 | (async () => { 28 | const { totalPRs, firstPR, lastPR } = await getUserInput( 29 | freeCodeCampRepo, 30 | defaultBase 31 | ); 32 | const prPropsToGet = ['number', 'labels', 'head']; 33 | const { openPRs } = await getPRs( 34 | freeCodeCampRepo, 35 | defaultBase, 36 | totalPRs, 37 | firstPR, 38 | lastPR, 39 | prPropsToGet 40 | ); 41 | 42 | if (openPRs.length) { 43 | savePrData(openPRs, firstPR, lastPR); 44 | log.start(); 45 | console.log('Starting error finding process...'); 46 | for (let count = 0; count < openPRs.length; count++) { 47 | let { 48 | number, 49 | labels, 50 | head: { sha: ref } 51 | } = openPRs[count]; 52 | const existingLabels = labels.map(({ name }) => name); 53 | 54 | if ( 55 | !existingLabels.includes('status: merge conflict') && 56 | !existingLabels.includes('status: needs update') && 57 | !existingLabels.includes('status: discussing') 58 | ) { 59 | const { data: statuses } = await octokit.repos.listStatusesForRef({ 60 | owner, 61 | repo: freeCodeCampRepo, 62 | ref 63 | }); 64 | if (statuses.length) { 65 | // first element contain most recent status 66 | const { state, target_url: targetUrl } = statuses[0]; 67 | const hasProblem = state === 'failure' || state === 'error'; 68 | if (hasProblem) { 69 | let buildNum = Number(targetUrl.match(/\/builds\/(\d+)\?/i)[1]); 70 | /* 71 | const logNumber = 'need to use Travis api to 72 | access the full log for the buildNum above' 73 | */ 74 | const logNumber = ++buildNum; 75 | const travisBaseUrl = 'https://api.travis-ci.org/v3/job/'; 76 | const travisLogUrl = `${travisBaseUrl + logNumber}/log.txt`; 77 | const response = await fetch(travisLogUrl); 78 | const logText = await response.text(); 79 | let error; 80 | for (let { error: errorDesc, regex } of errorsToFind) { 81 | regex = RegExp(regex); 82 | if (regex.test(logText)) { 83 | error = errorDesc; 84 | break; 85 | } 86 | } 87 | const errorDesc = error ? error : 'unknown error'; 88 | log.add(number, { number, errorDesc, buildLog: travisLogUrl }); 89 | } 90 | } 91 | } 92 | } 93 | } 94 | })() 95 | .then(() => { 96 | log.finish(); 97 | console.log('Successfully finished finding all specified errors.'); 98 | }) 99 | .catch((err) => { 100 | log.finish(); 101 | console.log(err); 102 | }); 103 | -------------------------------------------------------------------------------- /one-off-scripts/get-unknown-repo-prs.js: -------------------------------------------------------------------------------- 1 | /* 2 | This script was created to find all open PRs with unknown repos which 3 | potentially have merge conflicts. 4 | 5 | To run the script for a specific language, call the script with the language 6 | name as the first argument. 7 | 8 | Note: If any PR displayed in the console shows "unknown", you will need to rerun 9 | the script again. 10 | */ 11 | 12 | const { 13 | github: { owner, secret, freeCodeCampRepo, defaultBase } 14 | } = require('../lib/config'); 15 | const { Octokit } = require('@octokit/rest'); 16 | 17 | const octokit = new Octokit({ auth: secret }); 18 | 19 | const { getPRs, getUserInput } = require('../lib/get-prs'); 20 | const { ProcessingLog, rateLimiter } = require('../lib/utils'); 21 | const { validLabels } = require('../lib/validation/valid-labels'); 22 | 23 | let languageLabel; 24 | let [languageArg] = process.argv.slice(2); 25 | if (languageArg) { 26 | languageArg = languageArg.toLowerCase(); 27 | languageLabel = validLabels[languageArg] ? validLabels[languageArg] : null; 28 | } 29 | 30 | if (languageLabel) { 31 | console.log(`finding PRs with label = ${languageLabel}`); 32 | } 33 | 34 | const log = new ProcessingLog('unknown-repo-prs-with-merge-conflicts'); 35 | log.start(); 36 | (async () => { 37 | const { totalPRs, firstPR, lastPR } = await getUserInput( 38 | freeCodeCampRepo, 39 | defaultBase, 40 | 'all' 41 | ); 42 | const prPropsToGet = ['number', 'labels', 'user', 'head']; 43 | const { openPRs } = await getPRs( 44 | freeCodeCampRepo, 45 | defaultBase, 46 | totalPRs, 47 | firstPR, 48 | lastPR, 49 | prPropsToGet 50 | ); 51 | if (openPRs.length) { 52 | let count = 0; 53 | let mergeConflictCount = 0; 54 | 55 | for (let i = 0; i < openPRs.length; i++) { 56 | let { 57 | labels, 58 | number, 59 | head: { repo: headRepo } 60 | } = openPRs[i]; 61 | 62 | const hasLanguage = 63 | languageLabel && labels.some(({ name }) => languageLabel === name); 64 | 65 | if (headRepo === null && (!languageLabel || hasLanguage)) { 66 | let data = await octokit.pulls.get({ 67 | owner, 68 | repo: freeCodeCampRepo, 69 | number 70 | }); 71 | let mergeableState = data.data.mergeable_state; 72 | if (mergeableState === 'unknown') { 73 | // allow time to let GitHub determine status 74 | await rateLimiter(4000); 75 | data = await octokit.pulls.get({ 76 | owner, 77 | repo: freeCodeCampRepo, 78 | number 79 | }); 80 | mergeableState = data.data.mergeable_state; 81 | } 82 | count++; 83 | 84 | if (mergeableState === 'dirty' || mergeableState === 'unknown') { 85 | log.add(number, { number, mergeableState }); 86 | console.log(`${number} (${mergeableState})`); 87 | mergeConflictCount++; 88 | } 89 | if (count > 4000) { 90 | await rateLimiter(2350); 91 | } 92 | } 93 | } 94 | console.log( 95 | `There were ${mergeConflictCount} PRs with potential merge conflicts out of ${count} unknown repos received from GitHub` 96 | ); 97 | } else { 98 | throw 'There were no open PRs received from Github'; 99 | } 100 | })() 101 | .then(async () => { 102 | log.finish(); 103 | console.log('Finished finding unknown repo PRs with merge conflicts'); 104 | }) 105 | .catch((err) => { 106 | log.finish(); 107 | console.log(err); 108 | }); 109 | -------------------------------------------------------------------------------- /one-off-scripts/prs-with-merge-conflicts.js: -------------------------------------------------------------------------------- 1 | /* 2 | This script was created to find all open PRs that have merge 3 | conflicts and then add a the 'status: merge conflict' label to any PR 4 | which does not already have the label. 5 | 6 | To run the script for a specific language, call the script with the language 7 | name as the first argument. 8 | 9 | Note: It is possible that it could take more than 4 seconds for GitHub to 10 | determine if a PR is mergeable. If that happens, the PR will not be labeled. 11 | */ 12 | 13 | const { Octokit } = require('@octokit/rest'); 14 | const { 15 | github: { owner, secret, freeCodeCampRepo, defaultBase } 16 | } = require('../lib/config'); 17 | 18 | const octokit = new Octokit({ auth: secret }); 19 | 20 | const { getPRs, getUserInput } = require('../lib/get-prs'); 21 | const { ProcessingLog, rateLimiter } = require('../lib/utils'); 22 | const { addLabels } = require('../lib/pr-tasks'); 23 | const { validLabels } = require('../lib/validation/valid-labels'); 24 | 25 | let languageLabel; 26 | let [languageArg] = process.argv.slice(2); 27 | if (languageArg) { 28 | languageArg = languageArg.toLowerCase(); 29 | languageLabel = validLabels[languageArg] ? validLabels[languageArg] : null; 30 | } 31 | 32 | if (languageLabel) { 33 | console.log(`finding PRs with label = ${languageLabel}`); 34 | } 35 | 36 | const log = new ProcessingLog('prs-with-merge-conflicts'); 37 | log.start(); 38 | (async () => { 39 | const { totalPRs, firstPR, lastPR } = await getUserInput( 40 | freeCodeCampRepo, 41 | defaultBase, 42 | 'all' 43 | ); 44 | const prPropsToGet = ['number', 'labels', 'user']; 45 | const { openPRs } = await getPRs( 46 | freeCodeCampRepo, 47 | defaultBase, 48 | totalPRs, 49 | firstPR, 50 | lastPR, 51 | prPropsToGet 52 | ); 53 | if (openPRs.length) { 54 | let count = 0; 55 | let mergeConflictCount = 0; 56 | for (let i = 0; i < openPRs.length; i++) { 57 | let { labels, number } = openPRs[i]; 58 | 59 | const hasLanguage = 60 | languageLabel && labels.some(({ name }) => languageLabel === name); 61 | 62 | const hasMergeConflictLabel = labels.some( 63 | ({ name }) => 'status: merge conflict' === name 64 | ); 65 | 66 | if (!languageLabel || hasLanguage) { 67 | let data = await octokit.pulls.get({ 68 | owner, 69 | repo: freeCodeCampRepo, 70 | number 71 | }); 72 | let mergeableState = data.data.mergeable_state; 73 | count++; 74 | if (mergeableState === 'unknown') { 75 | await rateLimiter(4000); 76 | data = await octokit.pulls.get({ 77 | owner, 78 | repo: freeCodeCampRepo, 79 | number 80 | }); 81 | mergeableState = data.data.mergeable_state; 82 | count++; 83 | } 84 | 85 | if (mergeableState === 'dirty' && !hasMergeConflictLabel) { 86 | mergeConflictCount++; 87 | addLabels(number, ['status: merge conflict'], log); 88 | await rateLimiter(); 89 | } 90 | 91 | if (count > 4000) { 92 | await rateLimiter(2350); 93 | } 94 | } 95 | } 96 | console.log( 97 | `There were ${mergeConflictCount} PRs with potential merge conflicts out of ${count} PRs received from GitHub` 98 | ); 99 | } else { 100 | throw 'There were no open PRs received from Github'; 101 | } 102 | })() 103 | .then(async () => { 104 | log.finish(); 105 | console.log('Finished finding PRs with merge conflicts'); 106 | }) 107 | .catch((err) => { 108 | log.finish(); 109 | console.log(err); 110 | }); 111 | -------------------------------------------------------------------------------- /dashboard-app/client/src/components/Search.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import Input from './Input'; 4 | import PrResults from './PrResults'; 5 | import FilenameResults from './FilenameResults'; 6 | import SearchOption from './SearchOption'; 7 | 8 | import { ENDPOINT_PR, ENDPOINT_SEARCH } from '../constants'; 9 | class Search extends Component { 10 | state = { 11 | searchValue: '', 12 | selectedOption: 'pr', 13 | results: [], 14 | message: '' 15 | }; 16 | 17 | clearObj = { searchValue: '', results: [] }; 18 | 19 | inputRef = React.createRef(); 20 | 21 | handleInputEvent = (event) => { 22 | const { 23 | type, 24 | key, 25 | target: { value: searchValue } 26 | } = event; 27 | 28 | if (type === 'change') { 29 | if (this.state.selectedOption === 'pr') { 30 | if (Number(searchValue) || searchValue === '') { 31 | this.setState((prevState) => ({ searchValue, results: [] })); 32 | } 33 | } else { 34 | this.setState((prevState) => ({ searchValue, results: [] })); 35 | } 36 | } else if (type === 'keypress' && key === 'Enter') { 37 | this.searchPRs(searchValue); 38 | } 39 | }; 40 | 41 | handleButtonClick = () => { 42 | const { searchValue } = this.state; 43 | if (searchValue) { 44 | this.searchPRs(searchValue); 45 | } else { 46 | this.inputRef.current.focus(); 47 | } 48 | }; 49 | 50 | handleOptionChange = (changeEvent) => { 51 | const selectedOption = changeEvent.target.value; 52 | 53 | this.setState((prevState) => ({ selectedOption, ...this.clearObj })); 54 | this.inputRef.current.focus(); 55 | }; 56 | 57 | searchPRs = (value) => { 58 | const { selectedOption } = this.state; 59 | 60 | const fetchUrl = 61 | selectedOption === 'pr' 62 | ? `${ENDPOINT_PR}/${value}` 63 | : `${ENDPOINT_SEARCH}/?value=${value}`; 64 | 65 | fetch(fetchUrl) 66 | .then((response) => response.json()) 67 | .then(({ ok, message, results, rateLimitMessage }) => { 68 | if (ok) { 69 | this.setState((prevState) => ({ message, results })); 70 | } else if (rateLimitMessage) { 71 | this.setState((prevState) => ({ 72 | rateLimitMessage 73 | })); 74 | } 75 | }) 76 | .catch(() => { 77 | this.setState((prevState) => this.clearObj); 78 | }); 79 | }; 80 | 81 | componentDidMount() { 82 | this.inputRef.current.focus(); 83 | } 84 | 85 | render() { 86 | const { 87 | handleButtonClick, 88 | handleInputEvent, 89 | inputRef, 90 | handleOptionChange, 91 | state 92 | } = this; 93 | const { searchValue, message, results, selectedOption, rateLimitMessage } = 94 | state; 95 | 96 | return ( 97 | <> 98 |
    99 | 104 | PR # 105 | 106 | 111 | Filename 112 | 113 |
    114 | 119 | 120 | {message} 121 | {selectedOption === 'pr' && ( 122 | 127 | )} 128 | {selectedOption === 'filename' && ( 129 | 134 | )} 135 | 136 | ); 137 | } 138 | } 139 | 140 | export default Search; 141 | -------------------------------------------------------------------------------- /dashboard-app/client/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import FreeCodeCampLogo from './assets/freeCodeCampLogo'; 5 | import Tabs from './components/Tabs'; 6 | import Search from './components/Search'; 7 | import Pareto from './components/Pareto'; 8 | import Repos from './components/Repos'; 9 | import Footer from './components/Footer'; 10 | 11 | import { ENDPOINT_INFO } from './constants'; 12 | 13 | const PageContainer = styled.div` 14 | margin-top: 70px; 15 | display: flex; 16 | flex-direction: column; 17 | justify-content: center; 18 | align-items: center; 19 | @media (max-width: 991px) { 20 | margin-top: 135px; 21 | } 22 | `; 23 | 24 | const Container = styled.div` 25 | display: flex; 26 | flex-direction: column; 27 | justify-content: center; 28 | align-items: center; 29 | max-width: 960px; 30 | width: 90vw; 31 | padding: 15px; 32 | border-radius: 4px; 33 | box-shadow: 0 0 4px 0 #777; 34 | `; 35 | 36 | const AppNavBar = styled.nav` 37 | margin: 0; 38 | padding: 0; 39 | color: white; 40 | position: fixed; 41 | top: 0; 42 | left: 0; 43 | right: 0; 44 | display: flex; 45 | justify-content: space-between; 46 | align-items: center; 47 | background: ${({ theme }) => theme.primary}; 48 | @media (max-width: 991px) { 49 | flex-direction: column; 50 | } 51 | `; 52 | 53 | const logoStyle = { paddingLeft: '30px' }; 54 | 55 | const titleStyle = { margin: '0', padding: '0' }; 56 | 57 | class App extends Component { 58 | state = { 59 | view: 'search', 60 | footerInfo: null 61 | }; 62 | 63 | updateInfo() { 64 | fetch(ENDPOINT_INFO) 65 | .then((response) => response.json()) 66 | .then(({ ok, numPRs, prRange, lastUpdate }) => { 67 | if (ok) { 68 | const footerInfo = { numPRs, prRange, lastUpdate }; 69 | this.setState((prevState) => ({ footerInfo })); 70 | } 71 | }) 72 | .catch(() => { 73 | // do nothing 74 | }); 75 | } 76 | 77 | handleViewChange = ({ target: { id } }) => { 78 | const view = id.replace('tabs-', ''); 79 | this.setState((prevState) => ({ ...this.clearObj, view })); 80 | if (view === 'reports' || view === 'search') { 81 | this.updateInfo(); 82 | } 83 | }; 84 | 85 | componentDidMount() { 86 | this.updateInfo(); 87 | } 88 | 89 | render() { 90 | const { 91 | handleViewChange, 92 | state: { view, footerInfo } 93 | } = this; 94 | return ( 95 | <> 96 | 97 | 103 | 104 | 105 |

    Contributor Tools

    106 | 117 |
    118 | 119 | 120 | 121 | {view === 'search' && } 122 | {view === 'reports' && } 123 | {view === 'boilerplates' && ( 124 | repo._id.includes('boilerplate')} 127 | /> 128 | )} 129 | {view === 'other' && ( 130 | 133 | !repo._id.includes('boilerplate') && 134 | repo._id !== 'freeCodeCamp' 135 | } 136 | /> 137 | )} 138 | 139 | {footerInfo &&