├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .env.example ├── .github ├── dependabot.yml └── workflows │ └── nextjs.yml ├── .gitignore ├── .nojekyll ├── .prettierrc ├── .vscode └── launch.json ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── app ├── .eslintrc.js ├── .gitignore ├── .nojekyll ├── docs │ └── definitions.md ├── generated │ └── basePath.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── images │ │ └── logo.png │ ├── next.svg │ ├── thirteen.svg │ └── vercel.svg ├── scripts │ └── generate-basepath.js ├── src │ ├── components │ │ ├── DarkModeToggle.tsx │ │ ├── Documentation.tsx │ │ ├── Layout.tsx │ │ ├── RepositoriesTable.tsx │ │ └── TopicCell.tsx │ ├── data │ │ └── .gitignore │ ├── hooks │ │ └── useIsSSR.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── documentation.tsx │ │ └── index.tsx │ ├── styles │ │ └── globals.css │ └── types │ │ └── markdown.d.ts ├── tailwind.config.js └── tsconfig.json ├── assets └── preview.png ├── backend ├── .gitignore ├── build.config.ts ├── package-lock.json ├── package.json ├── src │ ├── fetchers │ │ ├── discussions.ts │ │ ├── index.ts │ │ ├── issues.ts │ │ ├── meta.ts │ │ ├── organization.ts │ │ └── repository.ts │ ├── index.ts │ └── lib │ │ └── octokit.ts ├── test │ └── index.test.ts └── tsconfig.json ├── config.yml ├── package-lock.json ├── package.json └── types └── index.ts /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/vscode/devcontainers/go:1.20-bullseye 2 | 3 | USER vscode -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Typescript", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "features": { 7 | "docker-in-docker": { 8 | "version": "latest", 9 | "moby": true 10 | }, 11 | "azure-cli": "latest", 12 | "ghcr.io/devcontainers/features/github-cli:1": {}, 13 | "ghcr.io/devcontainers/features/node:1": {} 14 | }, 15 | 16 | "remoteUser": "vscode", 17 | "customizations": { 18 | "vscode": { 19 | "settings": {} 20 | }, 21 | "extensions": [ 22 | "github.copilot", 23 | "dbaeumer.vscode-eslint", 24 | "esbenp.prettier-vscode" 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | GRAPHQL_TOKEN="" 2 | NEXT_TELEMETRY_DISABLED=1 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | registries: 4 | ghcr: 5 | type: docker-registry 6 | url: ghcr.io 7 | username: PAT 8 | password: '${{secrets.CONTAINER_BUILDER_TOKEN}}' 9 | updates: 10 | - package-ecosystem: 'gomod' 11 | directory: '/' 12 | schedule: 13 | interval: weekly 14 | commit-message: 15 | prefix: 'chore(deps)' 16 | groups: 17 | dependencies: 18 | applies-to: version-updates 19 | update-types: 20 | - 'minor' 21 | - 'patch' 22 | - package-ecosystem: 'npm' 23 | directory: '/' 24 | schedule: 25 | interval: weekly 26 | commit-message: 27 | prefix: 'chore(deps)' 28 | groups: 29 | dependencies: 30 | applies-to: version-updates 31 | update-types: 32 | - 'minor' 33 | - 'patch' 34 | - package-ecosystem: docker 35 | registries: 36 | - ghcr 37 | directory: '/' 38 | schedule: 39 | interval: weekly 40 | commit-message: 41 | prefix: 'chore(deps)' 42 | groups: 43 | dependencies: 44 | applies-to: version-updates 45 | update-types: 46 | - 'minor' 47 | - 'patch' 48 | - package-ecosystem: github-actions 49 | directory: '/' 50 | schedule: 51 | interval: weekly 52 | commit-message: 53 | prefix: 'chore(deps)' 54 | groups: 55 | dependencies: 56 | applies-to: version-updates 57 | update-types: 58 | - 'minor' 59 | - 'patch' 60 | -------------------------------------------------------------------------------- /.github/workflows/nextjs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Next.js site to Pages 2 | on: 3 | # Runs on pushes targeting the default branch 4 | push: 5 | branches: ['main'] 6 | # Allows you to run this workflow manually from the Actions tab 7 | workflow_dispatch: 8 | # Build step runs on each pull request 9 | pull_request: 10 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | env: 16 | GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }} 17 | ORGANIZATION_NAME: ${{ vars.ORGANIZATION_NAME }} 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: 'pages' 22 | cancel-in-progress: false 23 | jobs: 24 | # Build job 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4 30 | - name: Setup Node 31 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4 32 | with: 33 | node-version: '20.x' 34 | - name: Collect metrics and save output 35 | id: metrics 36 | run: | 37 | cd backend 38 | npm i 39 | npm run dev 40 | - name: Detect package manager 41 | id: detect-package-manager 42 | run: | 43 | if [ -f "${{ github.workspace }}/app/yarn.lock" ]; then 44 | echo "manager=yarn" >> $GITHUB_OUTPUT 45 | echo "command=install" >> $GITHUB_OUTPUT 46 | echo "runner=yarn" >> $GITHUB_OUTPUT 47 | echo "cache-dependency-path=**/yarn.lock" >> $GITHUB_OUTPUT 48 | exit 0 49 | elif [ -f "${{ github.workspace }}/app/package.json" ]; then 50 | echo "manager=npm" >> $GITHUB_OUTPUT 51 | echo "command=ci" >> $GITHUB_OUTPUT 52 | echo "runner=npx --no-install" >> $GITHUB_OUTPUT 53 | echo "cache-dependency-path=**/package-lock.json" >> $GITHUB_OUTPUT 54 | exit 0 55 | else 56 | echo "Unable to determine package manager" 57 | exit 1 58 | fi 59 | - name: Setup Pages 60 | uses: actions/configure-pages@1f0c5cde4bc74cd7e1254d0cb4de8d49e9068c7d # v4 61 | with: 62 | # Automatically inject basePath in your Next.js configuration file and disable 63 | # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized). 64 | # 65 | # You may remove this line if you want to manage the configuration yourself. 66 | static_site_generator: next 67 | - name: Restore cache 68 | uses: actions/cache@734d9cb93d6f7610c2400b0f789eaa6f9813e271 # v3 69 | with: 70 | path: | 71 | "${{ github.workspace }}/app/.next/cache" 72 | # Generate a new cache whenever packages or source files change. 73 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} 74 | # If source files changed but packages didn't, rebuild from a prior cache. 75 | restore-keys: | 76 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- 77 | - name: Install dependencies 78 | run: cd "${{ github.workspace }}/app" && ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} 79 | - name: Build with Next.js 80 | run: cd "${{ github.workspace }}/app" && ${{ steps.detect-package-manager.outputs.runner }} next build 81 | - name: Static HTML export with Next.js 82 | run: cd "${{ github.workspace }}/app" && ${{ steps.detect-package-manager.outputs.runner }} next export 83 | - name: Upload artifact 84 | uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 85 | with: 86 | path: '${{ github.workspace }}/app/out' 87 | # Deployment job 88 | deploy: 89 | environment: 90 | name: github-pages 91 | url: ${{ steps.deployment.outputs.page_url }} 92 | runs-on: ubuntu-latest 93 | if: ${{ github.event_name != 'pull_request' }} 94 | needs: build 95 | steps: 96 | - name: Deploy to GitHub Pages 97 | id: deployment 98 | uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # Data files - these get generated at buildtime. The README includes instructions for generating these. 39 | /src/data/data.json 40 | 41 | .env* 42 | !.env.example 43 | -------------------------------------------------------------------------------- /.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github-community-projects/org-metrics-dashboard/2fedffcdf769f29bc6e1732f494a30259d56f15f/.nojekyll -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 80, 5 | "tabWidth": 2 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "App: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "cd app && npm run dev" 9 | }, 10 | { 11 | "name": "App: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "App: debug full stack", 18 | "type": "node-terminal", 19 | "request": "launch", 20 | "command": "cd app && npm run dev", 21 | "serverReadyAction": { 22 | "pattern": "- Local:.+(https?://.+)", 23 | "uriFormat": "%s", 24 | "action": "debugWithChrome" 25 | } 26 | }, 27 | { 28 | "name": "Backend: Debug", 29 | "type": "node-terminal", 30 | "request": "launch", 31 | "command": "cd backend && npm run dev", 32 | "envFile": "${workspaceFolder}/.env" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github-community-projects/ospo -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@github.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | [fork]: https://github.com/github-community-projects/org-metrics-dashboard/fork 4 | [pr]: https://github.com/github-community-projects/org-metrics-dashboard/compare 5 | [code-of-conduct]: CODE_OF_CONDUCT.md 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 8 | 9 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE.md). 10 | 11 | Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 12 | 13 | ## Submitting a pull request 14 | 15 | 1. [Fork][fork] and clone the repository 16 | 1. Configure and install the dependencies: `npm i` 17 | 1. Make sure the tests pass on your machine: `npm t` 18 | 1. Create a new branch: `git checkout -b my-branch-name` 19 | 1. Make your change, add tests, and make sure the tests still pass 20 | 1. Push to your fork and [submit a pull request][pr] 21 | 1. Pat yourself on the back and wait for your pull request to be reviewed and merged. 22 | 23 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 24 | 25 | - Follow the style by fixing any ESLint errors. 26 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 27 | - Write a [good commit message](https://www.freecodecamp.org/news/how-to-write-better-git-commit-messages/). 28 | 29 | ## Resources 30 | 31 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 32 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 33 | - [GitHub Help](https://help.github.com) 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2024 GitHub Inc 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Org Metrics Dashboard 2 | 3 | An actions-powered dashboard to get an overview of your organization's open source repository health. 4 | 5 | [![Image preview](./assets/preview.png)](https://github-community-projects.github.io/org-metrics-dashboard) 6 | 7 | The dashboard provides a quick overview of your organization's public repositories. It fetches data from the GitHub API using actions and displays it in a github pages site. The dashboard provides the following information about your repositories: 8 | 9 | - License information 10 | - Issue and PR counts 11 | - Metrics around response times for issues and PRs 12 | 13 | Check out the live demo [here](https://github-community-projects.github.io/org-metrics-dashboard). 14 | 15 | ## Setting up the project for your organization 16 | 17 | ### Fork the repository 18 | 19 | You will need to [fork this repository](https://github.com/github-community-projects/org-metrics-dashboard/fork) into your org. Alternatively, you can clone this repository and push it to your org. 20 | 21 | ### Actions 22 | 23 | Since we use the GitHub API and actions to generate the data, you will need to enable actions for the repository. You can do this by going to the `Actions` tab in the repository and enabling actions. 24 | 25 | You will need to set a secret in the repository settings. The secret is a GitHub token that has admin read access to the organization. You can create a token by going to `Settings` -> `Developer settings` -> `Personal access tokens` and creating a new token with the following scopes. 26 | 27 | - public_repo 28 | - read:org 29 | - read:project 30 | 31 | > [!NOTE] 32 | > To fetch collaborator counts, you need to provide a token that is an admin of the organization. 33 | 34 | The secret should be named `GRAPHQL_TOKEN`. You can set this for your repository in `Settings` -> `Secrets and variables` -> `Actions`. 35 | 36 | ### Configuration 37 | 38 | There is a `config.yml` located in the root of the project that contains the configuration for the project. The configuration is as follows: 39 | 40 | ```yaml 41 | --- 42 | # The GitHub organization name 43 | organization: 'github-community-projects' 44 | # An ISO 8601 date string representing the date to start fetching data from 45 | since: '2024-02-22' 46 | # Path of the github pages site. i.e. github-community-projects.github.io/org-metrics-dashboard 47 | # This will typically be "/{REPOSITORY_NAME}" if you are hosting on GitHub pages 48 | basePath: '/org-metrics-dashboard' 49 | ``` 50 | 51 | - `organization`: The name of the organization you want to fetch data from. 52 | - `since`: The date to start fetching data from. This is useful if you want to fetch data from a specific date. 53 | - `basePath`: **Important**. This is the path where the site will be hosted. If you are hosting the site on GitHub pages, you will need to set this to the repository name for links and assets to work correctly. 54 | 55 | ## Development 56 | 57 | This project is split into two parts: 58 | 59 | - **app**: the code for the frontend 60 | - **backend**: the code for the backend and fetcher 61 | 62 | Both are written in TypeScript. We use [npm workspaces](https://docs.npmjs.com/cli/v8/using-npm/workspaces) to manage the dependencies between the two projects. 63 | 64 | ### Prerequisites 65 | 66 | - Node.js 20.X or later 67 | - npm 68 | 69 | ### Environment variables 70 | 71 | You will need a `.env` file in the root of the project: 72 | 73 | ```sh 74 | cp .env.example .env 75 | ``` 76 | 77 | The `GRAPHQL_TOKEN` token requires the following scopes: 78 | 79 | - public_repo 80 | - read:org 81 | - read:project 82 | 83 | > [!NOTE] 84 | > To fetch collaborator counts, you need to provide a token that is an admin of the organization. 85 | 86 | ### Installation 87 | 88 | ```sh 89 | npm i 90 | ``` 91 | 92 | ### Running the monorepo 93 | 94 | This will kick off both the fetcher and the app. 95 | 96 | ```sh 97 | npm run dev 98 | ``` 99 | 100 | ### Running each part separately 101 | 102 | If you wish to run the backend only: 103 | 104 | ```sh 105 | npm run dev:backend 106 | ``` 107 | 108 | If you wish to run the app only: 109 | 110 | > Note that you need to provide a valid `data.json` file in the `app/src/data` directory in order to render the app. 111 | 112 | ```sh 113 | npm run dev:app 114 | ``` 115 | 116 | ## License 117 | 118 | This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE.md) for the full terms. 119 | 120 | ## Maintainers 121 | 122 | Check out the [CODEOWNERS](./CODEOWNERS) file to see who to contact for code changes. 123 | 124 | ## Support 125 | 126 | If you need support using this project or have questions about it, please [open an issue in this repository](https://github.com/github-community-projects/org-metrics-dashboard/issues/new) and we'd be happy to help. Requests made directly to GitHub staff or the support team will be redirected here to open an issue. GitHub SLA's and support/services contracts do not apply to this repository. 127 | 128 | ## More OSPO Tools 129 | 130 | Looking for more resources for your open source program office (OSPO)? Check out the [`github-ospo`](https://github.com/github/github-ospo) repo for a variety of tools designed to support your needs. 131 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Thanks for helping make GitHub safe for everyone. 2 | 3 | # Security 4 | 5 | GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). 6 | 7 | Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation. 8 | 9 | ## Reporting Security Issues 10 | 11 | If you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure. 12 | 13 | **Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** 14 | 15 | Instead, please send an email to opensource-security[@]github.com. 16 | 17 | Please include as much of the information listed below as you can to help us better understand and resolve the issue: 18 | 19 | - The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) 20 | - Full paths of source file(s) related to the manifestation of the issue 21 | - The location of the affected source code (tag/branch/commit or direct URL) 22 | - Any special configuration required to reproduce the issue 23 | - Step-by-step instructions to reproduce the issue 24 | - Proof-of-concept or exploit code (if possible) 25 | - Impact of the issue, including how an attacker might exploit the issue 26 | 27 | This information will help us triage your report more quickly. 28 | 29 | ## Policy 30 | 31 | See [GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms) 32 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## How to file issues and get help 4 | 5 | This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue. 6 | 7 | For help or questions about using this project, please file an issue or start a GitHub discussion. 8 | 9 | org-metrics-dashboard is not actively developed but is maintained by GitHub staff **and the community**. We will do our best to respond to support, feature requests, and community questions in a timely manner. 10 | 11 | ## GitHub Support Policy 12 | 13 | Support for this project is limited to the resources listed above. 14 | -------------------------------------------------------------------------------- /app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /* eslint-disable import/no-nodejs-modules */ 3 | const glob = require('glob'); 4 | const { readFileSync } = require('fs'); 5 | const { dirname, join } = require('path'); 6 | 7 | const noRestrictedSyntaxRules = { 8 | 'no-restricted-syntax': [ 9 | 'error', 10 | { 11 | selector: 12 | "TSNonNullExpression>CallExpression[callee.property.name='getAttribute']", 13 | message: 14 | "Please check for null or use `|| ''` instead of `!` when calling `getAttribute`", 15 | }, 16 | { 17 | selector: 18 | 'MemberExpression[object.name=/^e/][property.name=/^(key(?:Code)?|which|(meta|ctrl|alt)Key)$/]', 19 | message: 'Please use `data-hotkey` instead of manual shortcut logic', 20 | }, 21 | { 22 | selector: "NewExpression[callee.name='URL'][arguments.length=1]", 23 | message: 24 | 'Please pass in `window.location.origin` as the 2nd argument to `new URL()`', 25 | }, 26 | { 27 | selector: "CallExpression[callee.name='unsafeHTML']", 28 | message: 29 | 'Use unsafeHTML sparingly. Please add an eslint-disable comment if you want to use this', 30 | }, 31 | ], 32 | }; 33 | 34 | const baseConfig = { 35 | parser: '@typescript-eslint/parser', 36 | plugins: [ 37 | '@typescript-eslint', 38 | 'compat', 39 | 'delegated-events', 40 | 'filenames', 41 | 'i18n-text', 42 | 'escompat', 43 | 'import', 44 | 'github', 45 | ], 46 | extends: [ 47 | 'plugin:github/internal', 48 | 'plugin:github/recommended', 49 | 'plugin:github/browser', 50 | 'plugin:escompat/typescript', 51 | 'plugin:import/typescript', 52 | 'plugin:react/recommended', 53 | 'plugin:react/jsx-runtime', 54 | 'plugin:react-hooks/recommended', 55 | 'plugin:primer-react/recommended', 56 | 'next', 57 | ], 58 | parserOptions: { 59 | project: './tsconfig.json', 60 | tsconfigRootDir: __dirname, 61 | }, 62 | ignorePatterns: ['*__generated__*'], 63 | rules: { 64 | 'prettier/prettier': 1, // We use prettier for formatting instead of ESLint 65 | 'import/no-unresolved': 0, // Handled by TypeScript instead 66 | 'import/extensions': [ 67 | 'error', 68 | { 69 | svg: 'always', 70 | }, 71 | ], 72 | 'compat/compat': ['error'], 73 | 'delegated-events/global-on': ['error'], 74 | 'delegated-events/no-high-freq': ['error'], 75 | 'escompat/no-dynamic-imports': 'off', 76 | 'escompat/no-nullish-coalescing': 'off', 77 | '@typescript-eslint/no-shadow': 'error', 78 | '@typescript-eslint/no-unused-vars': ['error'], 79 | 'no-unused-vars': 'off', 80 | '@typescript-eslint/explicit-module-boundary-types': 'off', 81 | 'valid-typeof': ['error', { requireStringLiterals: true }], 82 | 'github/no-inner-html': 'off', 83 | 'no-restricted-imports': ['error'], 84 | 'primer-react/no-deprecated-colors': ['error', { skipImportCheck: true }], 85 | 'primer-react/no-system-props': [ 86 | 'error', 87 | { includeUtilityComponents: true }, 88 | ], 89 | 'react/no-danger': ['error'], 90 | 'react/self-closing-comp': [ 91 | 'error', 92 | { 93 | component: true, 94 | html: true, 95 | }, 96 | ], 97 | 'react/jsx-no-constructed-context-values': ['error'], 98 | 'react-hooks/exhaustive-deps': [ 99 | 'warn', 100 | { 101 | additionalHooks: 'useHydratedEffect', 102 | }, 103 | ], 104 | }, 105 | settings: { 106 | polyfills: [ 107 | 'Request', 108 | 'window.customElements', 109 | 'window.requestIdleCallback', 110 | ], 111 | 'import/resolver': { 112 | node: { 113 | extensions: ['.js', '.ts', '.tsx', '.json', '.md'], 114 | moduleDirectory: ['app/src', 'app/node_modules'], 115 | }, 116 | typescript: {}, 117 | }, 118 | react: { 119 | version: 'detect', 120 | }, 121 | }, 122 | overrides: [ 123 | { 124 | files: ['*.ts?(x)'], 125 | rules: { 126 | '@typescript-eslint/no-unnecessary-type-assertion': 'error', 127 | '@typescript-eslint/no-confusing-non-null-assertion': 'error', 128 | '@typescript-eslint/no-extra-non-null-assertion': 'error', 129 | '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', 130 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'error', 131 | 132 | // recommended by typescript-eslint as the typechecker handles these out of the box 133 | // https://typescript-eslint.io/linting/troubleshooting/performance-troubleshooting/#eslint-plugin-import 134 | 'import/named': 'off', 135 | 'import/namespace': 'off', 136 | 'import/default': 'off', 137 | 'import/no-named-as-default-member': 'off', 138 | 139 | // muting an expensive rule that scans jsdoc comments looking for @deprecated notes 140 | 'import/no-deprecated': 'off', 141 | 'filenames/match-regex': 'off', 142 | }, 143 | }, 144 | { 145 | files: ['*.json'], 146 | rules: { 147 | 'filenames/match-regex': 'off', 148 | }, 149 | parserOptions: { 150 | project: null, 151 | }, 152 | }, 153 | { 154 | files: ['*.d.ts'], 155 | rules: { 156 | '@typescript-eslint/no-unused-vars': 'off', 157 | 'no-var': 'off', 158 | }, 159 | }, 160 | { 161 | files: ['eslintrc.js', '.eslintrc.js'], 162 | parserOptions: { 163 | project: null, 164 | }, 165 | env: { 166 | node: true, 167 | browser: false, 168 | }, 169 | rules: { 170 | 'filenames/match-regex': ['off'], 171 | 'import/no-commonjs': ['off'], 172 | }, 173 | }, 174 | ], 175 | }; 176 | 177 | const findDevPackages = () => { 178 | const uiPackagesGlob = join(__dirname, 'packages/*/package.json'); 179 | const packages = glob.sync(uiPackagesGlob, { 180 | ignore: 'node_modules/**', 181 | absolute: true, 182 | }); 183 | 184 | return packages.reduce((acc, package) => { 185 | const pkg = JSON.parse(readFileSync(package)); 186 | 187 | if (pkg.dev) acc.push(`${dirname(package)}/**/*.{mjs,js,ts,tsx}`); 188 | 189 | return acc; 190 | }, []); 191 | }; 192 | 193 | module.exports = { 194 | ...baseConfig, 195 | root: true, 196 | rules: { 197 | ...baseConfig.rules, 198 | 'eslint-comments/no-use': [ 199 | 'error', 200 | { allow: ['eslint-disable', 'eslint-disable-next-line'] }, 201 | ], 202 | 'import/no-extraneous-dependencies': [ 203 | 'error', 204 | { 205 | devDependencies: [ 206 | '**/__tests__/**/*.{ts,tsx}', 207 | '**/__browser-tests__/**/*.ts', 208 | '**/test-utils/**/*.{ts,tsx}', 209 | '**/*.stories.*', 210 | '**/jest.config.js', 211 | ...findDevPackages(), 212 | ], 213 | }, 214 | ], 215 | }, 216 | overrides: [ 217 | ...baseConfig.overrides, 218 | { 219 | files: ['*.tsx'], 220 | excludedFiles: ['**/__tests__/**'], 221 | rules: { 222 | 'i18n-text/no-en': 'off', 223 | // 'filenames/match-regex': [2, '^[A-Z][a-zA-Z]+(.[a-z0-9-]+)?$'], 224 | 'import/extensions': 'off', 225 | }, 226 | }, 227 | { 228 | files: ['*.ts'], 229 | rules: noRestrictedSyntaxRules, 230 | }, 231 | ], 232 | }; 233 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # Data files - these get generated at buildtime. The README includes instructions for generating these. 39 | /src/data/data.json 40 | 41 | generated/** 42 | -------------------------------------------------------------------------------- /app/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github-community-projects/org-metrics-dashboard/2fedffcdf769f29bc6e1732f494a30259d56f15f/app/.nojekyll -------------------------------------------------------------------------------- /app/docs/definitions.md: -------------------------------------------------------------------------------- 1 | ## Welcome to the Open-Source Metrics Documentation 2 | 3 | #### How to Read World Health Organization Open-Source Dashboard 4 | 5 | The Dashboard is currently a snapshot of data from the **World Health Organization** Repositories. All the metrics are based on the _Last Updated Date_ reflected on the top of the dashboard. 6 | 7 | ### Metrics and Definitions 8 | 9 | `Collaborators:` Collaborator is an individual that can read and write to the current repository. Metric calculates the current number of Collaborators. 10 | 11 | `Watchers:` The number of users who are tracking a particular repository, receiving notifications regarding its activity. 12 | 13 | `Open Issues:` Issues are tickets that represent bugs or new features that other people report and can collaborate on. Open issues are total of unresolved issues. 14 | 15 | `Open PR's:` A pull request is a request to make a change to the files of the project (code, documentation, README changes, etc.). Open PR's are the total of pull requests with status Open. 16 | 17 | `Merge PR's`: A total of Pull Requests that are accepted into the code base of the project. 18 | 19 | `Total Fork:` A fork is a copy of the current repository belonging to another user. Total Forks is how many times the repository has been copied. 20 | 21 | `Open Issues Median Age:` Median number of days for all issues with Open status. If the issue age is zero (meaning there are no open issues), we display "N/A". 22 | 23 | `Close Issues Median Age:` Median number of days for all issues with Closed status. If the issue age is zero (meaning there are no open issues), we display "N/A". 24 | 25 | `Issue Response Median Age:` Median response time of issues that has a response. If the issue response age is zero (meaning there are no comments), we display "N/A". If the first response is by the creator of the issue the comment is not counted. 26 | 27 | `Issue Response Average Age:` Average response time of issues that has a response. If the issue response age is zero (meaning there are no comments), we display "N/A". If the first response is by the creator of the issue the comment is not counted. Calculation is Issues Total Response Time divide by Total Issues 28 | -------------------------------------------------------------------------------- /app/generated/basePath.ts: -------------------------------------------------------------------------------- 1 | export const basePath = ''; 2 | -------------------------------------------------------------------------------- /app/next.config.js: -------------------------------------------------------------------------------- 1 | const yaml = require('yaml'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | const readConfig = () => { 6 | const file = fs.readFileSync( 7 | path.resolve(__dirname, '..', 'config.yml'), 8 | 'utf8', 9 | ); 10 | return yaml.parse(file); 11 | }; 12 | 13 | const config = readConfig(); 14 | const productionBasePath = config.basePath ?? ''; 15 | 16 | /** @type {import('next').NextConfig} */ 17 | const nextConfig = { 18 | compiler: { 19 | styledComponents: true, 20 | }, 21 | reactStrictMode: true, 22 | productionBrowserSourceMaps: true, 23 | pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx', 'json'], 24 | basePath: process.env.NODE_ENV === 'development' ? '' : productionBasePath, 25 | webpack: (config) => { 26 | config.module.rules.push({ 27 | test: /\.md$/, 28 | use: 'raw-loader', 29 | }); 30 | return config; 31 | }, 32 | images: { 33 | unoptimized: true, 34 | }, 35 | }; 36 | 37 | module.exports = nextConfig; 38 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "NODE_ENV=development node scripts/generate-basepath.js && next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "prepare": "node scripts/generate-basepath.js" 11 | }, 12 | "dependencies": { 13 | "@primer/octicons-react": "^19.13.0", 14 | "@primer/react": "^36.12.0", 15 | "@tailwindcss/typography": "^0.5.15", 16 | "autoprefixer": "^10.4.20", 17 | "file-saver": "^2.0.5", 18 | "json-2-csv": "^5.5.7", 19 | "marked": "^12.0.1", 20 | "next": "^13", 21 | "next-themes": "^0.4.4", 22 | "postcss": "^8.4.49", 23 | "raw-loader": "^4.0.2", 24 | "react": "^18.2.0", 25 | "react-data-grid": "^7.0.0-beta.43", 26 | "react-dom": "^18.2.0", 27 | "react-tiny-popover": "^8.1.4", 28 | "styled-components": "^5.3.11", 29 | "tailwindcss": "^3.4.16", 30 | "typescript": "^5.7.2", 31 | "usehooks-ts": "^3.1.0" 32 | }, 33 | "homepage": "https://sbv-world-health-org-metrics.github.io/sbv-world-health-org-metrics/", 34 | "devDependencies": { 35 | "@types/file-saver": "^2.0.7", 36 | "@types/glob": "^8.1.0", 37 | "@types/js-cookie": "^3.0.6", 38 | "@types/node": "20.11.30", 39 | "@types/react": "18.2.67", 40 | "@types/react-dom": "18.2.22", 41 | "@typescript-eslint/eslint-plugin": "^7.3.1", 42 | "@typescript-eslint/parser": "^7.3.1", 43 | "eslint": "^8.57.0", 44 | "eslint-config-next": "^13", 45 | "eslint-import-resolver-typescript": "^3.7.0", 46 | "eslint-plugin-compat": "^4.2.0", 47 | "eslint-plugin-delegated-events": "^1.0.0", 48 | "eslint-plugin-github": "^4.10.2", 49 | "eslint-plugin-import": "^2.31.0", 50 | "eslint-plugin-primer-react": "^4.1.2", 51 | "eslint-plugin-react": "^7.37.2", 52 | "glob": "^10.3.10", 53 | "prettier": "^3.4.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'tailwindcss/nesting': {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github-community-projects/org-metrics-dashboard/2fedffcdf769f29bc6e1732f494a30259d56f15f/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github-community-projects/org-metrics-dashboard/2fedffcdf769f29bc6e1732f494a30259d56f15f/app/public/images/logo.png -------------------------------------------------------------------------------- /app/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/scripts/generate-basepath.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const nextConfig = require('../next.config'); 4 | 5 | const basePath = nextConfig.basePath ?? ''; 6 | 7 | const outputPath = path.join(__dirname, '..', 'generated'); 8 | 9 | // Check if file exists, create if not 10 | if (!fs.existsSync(outputPath)) { 11 | fs.mkdirSync(outputPath); 12 | } 13 | 14 | fs.writeFileSync( 15 | `${outputPath}/basePath.ts`, 16 | `export const basePath = '${basePath}'; 17 | `, 18 | ); 19 | -------------------------------------------------------------------------------- /app/src/components/DarkModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { Text, ToggleSwitch, useTheme as primerUseTheme } from '@primer/react'; 2 | import { useTheme } from 'next-themes'; 3 | 4 | const DarkModeToggle = () => { 5 | const { theme, setTheme } = useTheme(); 6 | const { setColorMode } = primerUseTheme(); 7 | 8 | const setThemePage = () => { 9 | if (theme === 'dark') { 10 | setTheme('light'); 11 | setColorMode('light'); 12 | } else { 13 | setTheme('dark'); 14 | setColorMode('dark'); 15 | } 16 | }; 17 | return ( 18 | <> 19 | 20 | Toggle Mode 21 | 22 | setThemePage()} 26 | /> 27 | 28 | ); 29 | }; 30 | 31 | export default DarkModeToggle; 32 | -------------------------------------------------------------------------------- /app/src/components/Documentation.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@primer/react'; 2 | import { marked } from 'marked'; 3 | import Docs from '../../docs/definitions.md'; 4 | 5 | const convertMarkdownToHTML = (markdown: string): string => { 6 | return marked(markdown) as string; 7 | }; 8 | 9 | const Documentation = () => { 10 | const html = convertMarkdownToHTML(Docs); 11 | 12 | return ( 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default Documentation; 20 | -------------------------------------------------------------------------------- /app/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from 'usehooks-ts'; 2 | 3 | import { Box, Flash, IconButton, TabNav, Text } from '@primer/react'; 4 | import Image from 'next/image'; 5 | 6 | import { useIsSSR } from '@/hooks/useIsSSR'; 7 | import { XIcon } from '@primer/octicons-react'; 8 | import { useRouter } from 'next/router'; 9 | import { FC, PropsWithChildren } from 'react'; 10 | import { basePath } from '../../generated/basePath'; 11 | import data from '../data/data.json'; 12 | 13 | export const Layout: FC = ({ children }) => { 14 | const router = useRouter(); 15 | const [showBanner, setShowBanner] = useLocalStorage('show-banner', false); 16 | const isSSR = useIsSSR(); 17 | 18 | return ( 19 |
20 | 21 | {`${data.orgInfo.name} 28 | 29 | {data.orgInfo.name} Open Source Dashboard 30 | 31 | 32 | 33 | 34 | This project includes metrics about the Open Source repositories for{' '} 35 | {data.orgInfo.name}. 36 | 37 | 38 | {!isSSR && showBanner && ( 39 | 40 | 48 | 49 | Open Source Health Metrics for{' '} 50 | {data.orgInfo.name}. Visit 51 | the Documentation page to learn more about how these metrics are 52 | calculated. 53 | 54 | 55 | setShowBanner(false)} 57 | variant="invisible" 58 | icon={XIcon} 59 | aria-label="Dismiss" 60 | sx={{ svg: { margin: '0', color: 'fg.muted' } }} 61 | /> 62 | 63 | 64 | 65 | )} 66 | 67 | 71 | Repositories 72 | 73 | 77 | Documentation 78 | 79 | 80 | {children} 81 |
82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /app/src/components/RepositoriesTable.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable primer-react/a11y-tooltip-interactive-trigger */ 2 | import { 3 | InfoIcon, 4 | TriangleDownIcon, 5 | TriangleUpIcon, 6 | XIcon, 7 | } from '@primer/octicons-react'; 8 | 9 | import { 10 | ActionList, 11 | Box, 12 | Button, 13 | Checkbox, 14 | FormControl, 15 | Text, 16 | TextInput, 17 | Tooltip, 18 | } from '@primer/react'; 19 | import { json2csv } from 'json-2-csv'; 20 | import DataGrid, { 21 | Column, 22 | type RenderHeaderCellProps, 23 | type SortColumn, 24 | } from 'react-data-grid'; 25 | import { Popover } from 'react-tiny-popover'; 26 | 27 | import { saveAs } from 'file-saver'; 28 | import { 29 | createContext, 30 | FC, 31 | KeyboardEvent, 32 | ReactElement, 33 | useCallback, 34 | useContext, 35 | useRef, 36 | useState, 37 | } from 'react'; 38 | 39 | import { RepositoryResult } from '../../../types'; 40 | import Data from '../data/data.json'; 41 | import TopicCell from './TopicCell'; 42 | 43 | const repos = Object.values(Data['repositories']) as RepositoryResult[]; 44 | 45 | function inputStopPropagation(event: KeyboardEvent) { 46 | event.stopPropagation(); 47 | } 48 | 49 | type Filter = { 50 | repositoryName?: Record; 51 | licenseName?: Record; 52 | topics?: Record; 53 | collaboratorsCount?: Array; 54 | watchersCount?: Array; 55 | openIssuesCount?: Array; 56 | openPullRequestsCount?: Array; 57 | closedIssuesCount?: Array; 58 | mergedPullRequestsCount?: Array; 59 | forksCount?: Array; 60 | openIssuesMedianAge?: Array; 61 | openIssuesAverageAge?: Array; 62 | closedIssuesMedianAge?: Array; 63 | closedIssuesAverageAge?: Array; 64 | issuesResponseMedianAge?: Array; 65 | issuesResponseAverageAge?: Array; 66 | }; 67 | 68 | type SelectOption = { 69 | label: string | number; 70 | value: string | number; 71 | }; 72 | 73 | const millisecondsToDisplayString = (milliseconds: number) => { 74 | const days = milliseconds / 1000 / 60 / 60 / 24; 75 | if (days === 0) { 76 | return 'N/A'; 77 | } 78 | if (days < 1) { 79 | return `<1 day`; 80 | } 81 | 82 | if (days < 2) { 83 | return `1 day`; 84 | } 85 | 86 | return `${Math.floor(days)} days`; 87 | }; 88 | 89 | // This selects a field to populate a dropdown with 90 | const dropdownOptions = ( 91 | field: keyof RepositoryResult, 92 | filter = '', 93 | ): SelectOption[] => { 94 | let options = []; 95 | if (field === 'topics') { 96 | options = Array.from(new Set(repos.flatMap((repo) => repo.topics))).sort(); 97 | } else { 98 | options = Array.from(new Set(repos.map((repo) => repo[field]))); 99 | } 100 | return options 101 | .map((fieldName) => ({ 102 | // some fields are boolean (hasXxEnabled), so we need to convert them to strings 103 | label: typeof fieldName === 'boolean' ? fieldName.toString() : fieldName, 104 | value: typeof fieldName === 'boolean' ? fieldName.toString() : fieldName, 105 | })) 106 | .filter((fieldName) => 107 | (fieldName.value as string).toLowerCase().includes(filter.toLowerCase()), 108 | ); 109 | }; 110 | 111 | // Helper function to get the selected option value from a filter and field 112 | const getSelectedOption = ( 113 | filters: Filter, 114 | filterName: keyof Filter, 115 | filterField: string, 116 | defaultValue = false, 117 | ) => 118 | (filters[filterName] as Record)[filterField] ?? defaultValue; 119 | 120 | // Renderer for the min/max filter inputs 121 | const MinMaxRenderer: FC<{ 122 | headerCellProps: RenderHeaderCellProps; 123 | filters: Filter; 124 | updateFilters: ((filters: Filter) => void) & 125 | ((filters: (updatedFilters: Filter) => Filter) => void); 126 | filterName: keyof Filter; 127 | }> = ({ headerCellProps, filters, updateFilters, filterName }) => { 128 | return ( 129 | {...headerCellProps}> 130 | {({ ...rest }) => ( 131 | 132 | 133 | 134 | Min 135 | 136 | { 143 | updateFilters((globalFilters) => ({ 144 | ...globalFilters, 145 | [filterName]: [ 146 | Number(e.target.value), 147 | ( 148 | globalFilters[filterName] as Array 149 | )?.[1], 150 | ], 151 | })); 152 | }} 153 | onKeyDown={inputStopPropagation} 154 | onClick={(e) => e.stopPropagation()} 155 | /> 156 | 157 | 158 | 159 | Max 160 | 161 | 168 | updateFilters({ 169 | ...filters, 170 | [filterName]: [0, Number(e.target.value)], 171 | }) 172 | } 173 | onKeyDown={inputStopPropagation} 174 | onClick={(e) => e.stopPropagation()} 175 | /> 176 | 177 | 178 | )} 179 | 180 | ); 181 | }; 182 | 183 | // Renderer for the searchable select filter 184 | const SearchableSelectRenderer: FC<{ 185 | headerCellProps: RenderHeaderCellProps; 186 | filters: Filter; 187 | updateFilters: ((filters: Filter) => void) & 188 | ((filters: (newFilters: Filter) => Filter) => void); 189 | filterName: keyof Filter; 190 | }> = ({ headerCellProps, filters, updateFilters, filterName }) => { 191 | const [filteredOptions, setFilteredOptions] = useState(''); 192 | const allSelectOptions = dropdownOptions(filterName, filteredOptions); 193 | 194 | return ( 195 | {...headerCellProps}> 196 | {({ ...rest }) => ( 197 | 198 | setFilteredOptions(e.target.value)} 203 | trailingAction={ 204 | { 206 | setFilteredOptions(''); 207 | }} 208 | icon={XIcon} 209 | aria-label="Clear input" 210 | sx={{ color: 'fg.subtle' }} 211 | /> 212 | } 213 | /> 214 | 215 | 216 | { 218 | updateFilters((otherFilters) => ({ 219 | ...otherFilters, 220 | [filterName]: { 221 | ...otherFilters[filterName], 222 | all: !getSelectedOption(filters, filterName, 'all', true), 223 | }, 224 | })); 225 | }} 226 | > 227 | 228 | 237 | 238 | All 239 | 240 | {allSelectOptions.map((selectOption) => { 241 | if (selectOption.value === '') { 242 | return ( 243 | <> 244 | { 246 | updateFilters((otherFilters) => ({ 247 | ...otherFilters, 248 | [filterName]: { 249 | ...otherFilters[filterName], 250 | [selectOption.value]: !getSelectedOption( 251 | filters, 252 | filterName, 253 | selectOption.value as string, 254 | ), 255 | }, 256 | })); 257 | }} 258 | > 259 | 260 | 265 | )?.[selectOption.value] ?? false 266 | } 267 | /> 268 | 269 | No License 270 | 271 | 272 | ); 273 | } 274 | 275 | return ( 276 | <> 277 | { 279 | updateFilters((otherFilters) => ({ 280 | ...otherFilters, 281 | [filterName]: { 282 | ...otherFilters[filterName], 283 | [selectOption.value]: !getSelectedOption( 284 | filters, 285 | filterName, 286 | selectOption.value as string, 287 | ), 288 | }, 289 | })); 290 | }} 291 | > 292 | 293 | 301 | 302 | {selectOption.value} 303 | 304 | 305 | ); 306 | })} 307 | 308 | 309 | 310 | )} 311 | 312 | ); 313 | }; 314 | 315 | // Wrapper for rendering column header cell 316 | const HeaderCellRenderer = ({ 317 | tabIndex, 318 | column, 319 | children: filterFunction, 320 | sortDirection, 321 | }: RenderHeaderCellProps & { 322 | children: (args: { tabIndex: number; filters: Filter }) => ReactElement; 323 | }) => { 324 | const filters = useContext(FilterContext)!; 325 | const clickMeButtonRef = useRef(null); 326 | const [isPopoverOpen, setIsPopoverOpen] = useState(false); 327 | 328 | return ( 329 |
330 |
{column.name}
331 |
332 | {sortDirection === 'DESC' ? ( 333 | 334 | ) : sortDirection === 'ASC' ? ( 335 | 336 | ) : ( 337 | 338 | )} 339 | setIsPopoverOpen(false)} 344 | ref={clickMeButtonRef} // if you'd like a ref to your popover's child, you can grab one here 345 | content={() => ( 346 | // The click handler here is used to stop the header from being sorted 347 | e.stopPropagation()} 350 | sx={{ 351 | backgroundColor: 'Background', 352 | border: '1px solid', 353 | borderColor: 'border.default', 354 | }} 355 | > 356 | 357 | Filter by {column.name} 358 | {filterFunction({ tabIndex, filters })} 359 | 360 | 361 | )} 362 | > 363 | 373 | 374 |
375 |
376 | ); 377 | }; 378 | 379 | // Context is needed to read filter values otherwise columns are 380 | // re-created when filters are changed and filter loses focus 381 | const FilterContext = createContext(undefined); 382 | 383 | type Comparator = (a: RepositoryResult, b: RepositoryResult) => number; 384 | 385 | const getComparator = (sortColumn: keyof RepositoryResult): Comparator => { 386 | switch (sortColumn) { 387 | // number based sorting 388 | case 'closedIssuesCount': 389 | case 'collaboratorsCount': 390 | case 'discussionsCount': 391 | case 'forksCount': 392 | case 'totalIssuesCount': 393 | case 'mergedPullRequestsCount': 394 | case 'openIssuesCount': 395 | case 'openPullRequestsCount': 396 | case 'projectsCount': 397 | case 'watchersCount': 398 | case 'openIssuesMedianAge': 399 | case 'openIssuesAverageAge': 400 | case 'closedIssuesMedianAge': 401 | case 'closedIssuesAverageAge': 402 | case 'issuesResponseMedianAge': 403 | case 'issuesResponseAverageAge': 404 | return (a, b) => { 405 | if (a[sortColumn] === b[sortColumn]) { 406 | return 0; 407 | } 408 | 409 | if (a[sortColumn] > b[sortColumn]) { 410 | return 1; 411 | } 412 | 413 | return -1; 414 | }; 415 | 416 | // alphabetical sorting 417 | case 'licenseName': 418 | case 'repoNameWithOwner': 419 | case 'repositoryName': 420 | return (a, b) => { 421 | return a[sortColumn] 422 | .toLowerCase() 423 | .localeCompare(b[sortColumn].toLowerCase()); 424 | }; 425 | 426 | // Multi option, alphabetical 427 | case 'topics': 428 | return (a, b) => { 429 | const first = a[sortColumn].sort()[0]; 430 | const second = b[sortColumn].sort()[0]; 431 | if (!second) return -1; 432 | if (!first) return 1; 433 | return first.toLowerCase().localeCompare(second.toLowerCase()); 434 | }; 435 | 436 | default: 437 | throw new Error(`unsupported sortColumn: "${sortColumn}"`); 438 | } 439 | }; 440 | 441 | // Default set of filters 442 | const defaultFilters: Filter = { 443 | repositoryName: { 444 | all: true, 445 | }, 446 | licenseName: { 447 | all: true, 448 | }, 449 | topics: { 450 | all: true, 451 | }, 452 | }; 453 | 454 | // Helper for generating the csv blob 455 | const generateCSV = (data: RepositoryResult[]): Blob => { 456 | const output = json2csv(data); 457 | return new Blob([output], { type: 'text/csv' }); 458 | }; 459 | 460 | const RepositoriesTable = () => { 461 | const [globalFilters, setGlobalFilters] = useState(defaultFilters); 462 | 463 | // This needs a type, technically it's a Column but needs to be typed 464 | const labels: Record> = { 465 | Name: { 466 | key: 'repositoryName', 467 | name: 'Name', 468 | frozen: true, 469 | renderHeaderCell: (p) => ( 470 | 476 | ), 477 | renderCell: (props) => ( 478 | 484 | {props.row.repositoryName} 485 | 486 | ), 487 | }, 488 | Topics: { 489 | key: 'topics', 490 | name: 'Topics', 491 | width: 275, 492 | renderHeaderCell: (p) => { 493 | return ( 494 | 500 | ); 501 | }, 502 | renderCell: (props) => { 503 | // tabIndex === 0 is used as a proxy when the Cell is selected. See https://github.com/adazzle/react-data-grid/pull/3236 504 | const isSelected = props.tabIndex === 0; 505 | return ; 506 | }, 507 | }, 508 | License: { 509 | key: 'licenseName', 510 | name: 'License', 511 | 512 | renderHeaderCell: (p) => ( 513 | 519 | ), 520 | }, 521 | Collaborators: { 522 | key: 'collaboratorsCount', 523 | name: 'Collaborators', 524 | renderHeaderCell: (p) => { 525 | return ( 526 | 532 | ); 533 | }, 534 | }, 535 | Watchers: { 536 | key: 'watchersCount', 537 | name: 'Watchers', 538 | 539 | renderHeaderCell: (p) => { 540 | return ( 541 | 547 | ); 548 | }, 549 | }, 550 | 'Open Issues': { 551 | key: 'openIssuesCount', 552 | name: 'Open Issues', 553 | 554 | renderHeaderCell: (p) => { 555 | return ( 556 | 562 | ); 563 | }, 564 | }, 565 | 'Closed Issues': { 566 | key: 'closedIssuesCount', 567 | name: 'Closed Issues', 568 | 569 | renderHeaderCell: (p) => { 570 | return ( 571 | 577 | ); 578 | }, 579 | }, 580 | 'Open PRs': { 581 | key: 'openPullRequestsCount', 582 | name: 'Open PRs', 583 | 584 | renderHeaderCell: (p) => { 585 | return ( 586 | 592 | ); 593 | }, 594 | }, 595 | 'Merged PRs': { 596 | key: 'mergedPullRequestsCount', 597 | name: 'Merged PRs', 598 | 599 | renderHeaderCell: (p) => { 600 | return ( 601 | 607 | ); 608 | }, 609 | }, 610 | Forks: { 611 | key: 'forksCount', 612 | name: 'Total Forks', 613 | 614 | renderHeaderCell: (p) => { 615 | return ( 616 | 622 | ); 623 | }, 624 | }, 625 | OpenIssuesMedianAge: { 626 | key: 'openIssuesMedianAge', 627 | name: 'Open Issues Median Age', 628 | renderHeaderCell: (p) => { 629 | return ( 630 | 636 | ); 637 | }, 638 | renderCell: (p) => { 639 | return millisecondsToDisplayString(p.row.openIssuesMedianAge); 640 | }, 641 | }, 642 | OpenIssuesAverageAge: { 643 | key: 'openIssuesAverageAge', 644 | name: 'Open Issues Average Age', 645 | renderHeaderCell: (p) => { 646 | return ( 647 | 653 | ); 654 | }, 655 | renderCell: (p) => { 656 | return millisecondsToDisplayString(p.row.openIssuesAverageAge); 657 | }, 658 | }, 659 | ClosedIssuesMedianAge: { 660 | key: 'closedIssuesMedianAge', 661 | name: 'Closed Issues Median Age', 662 | renderHeaderCell: (p) => { 663 | return ( 664 | 670 | ); 671 | }, 672 | renderCell: (p) => { 673 | return ( 674 |
675 | {millisecondsToDisplayString(p.row.closedIssuesMedianAge)} 676 |
677 | ); 678 | }, 679 | }, 680 | ClosedIssuesAverageAge: { 681 | key: 'closedIssuesAverageAge', 682 | name: 'Closed Issues Average Age', 683 | renderHeaderCell: (p) => { 684 | return ( 685 | 691 | ); 692 | }, 693 | renderCell: (p) => { 694 | return ( 695 |
696 | {millisecondsToDisplayString(p.row.closedIssuesAverageAge)} 697 |
698 | ); 699 | }, 700 | }, 701 | IssuesResponseMedianAge: { 702 | key: 'issuesResponseMedianAge', 703 | name: 'Issues Response Median Age', 704 | renderHeaderCell: (p) => { 705 | return ( 706 | 712 | ); 713 | }, 714 | renderCell: (p) => { 715 | return ( 716 |
717 | {millisecondsToDisplayString(p.row.issuesResponseMedianAge)} 718 |
719 | ); 720 | }, 721 | }, 722 | IssuesResponseAverageAge: { 723 | key: 'issuesResponseAverageAge', 724 | name: 'Issues Response Average Age', 725 | renderHeaderCell: (p) => { 726 | return ( 727 | 733 | ); 734 | }, 735 | renderCell: (p) => { 736 | return ( 737 |
738 | {millisecondsToDisplayString(p.row.issuesResponseAverageAge)} 739 |
740 | ); 741 | }, 742 | }, 743 | } as const; 744 | 745 | const dataGridColumns = Object.entries(labels).map( 746 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 747 | ([_, columnProps]) => columnProps, 748 | ); 749 | 750 | const subTitle = `${repos.length} total repositories`; 751 | 752 | const [sortColumns, setSortColumns] = useState([]); 753 | 754 | const sortRepos = (inputRepos: RepositoryResult[]) => { 755 | if (sortColumns.length === 0) { 756 | return repos; 757 | } 758 | 759 | const sortedRows = [...inputRepos].sort((a, b) => { 760 | for (const sort of sortColumns) { 761 | const comparator = getComparator( 762 | sort.columnKey as keyof RepositoryResult, 763 | ); 764 | const compResult = comparator(a, b); 765 | if (compResult !== 0) { 766 | return sort.direction === 'ASC' ? compResult : -compResult; 767 | } 768 | } 769 | return 0; 770 | }); 771 | 772 | return sortedRows; 773 | }; 774 | 775 | const testTimeBasedFilter = ( 776 | minDays: number | undefined, 777 | maxDays: number | undefined, 778 | timeInMs: number, 779 | ) => { 780 | const timeInDays = Math.floor(timeInMs / 1000 / 60 / 60 / 24); 781 | minDays = minDays || 0; 782 | maxDays = maxDays || Infinity; 783 | 784 | return timeInDays >= minDays && timeInDays <= maxDays; 785 | }; 786 | 787 | /** 788 | * Uses globalFilters to filter the repos that are then passed to sortRepos 789 | * 790 | * NOTE: We use some hacks like adding 'all' to the licenseName filter to 791 | * make it easier to filter the repos. 792 | * 793 | * This is kind of a mess, but it works 794 | */ 795 | const filterRepos = useCallback( 796 | (inputRepos: RepositoryResult[]) => { 797 | const result = inputRepos.filter((repo) => { 798 | return ( 799 | ((globalFilters.repositoryName?.[repo.repositoryName] ?? false) || 800 | (globalFilters.repositoryName?.['all'] ?? false)) && 801 | ((globalFilters.topics && 802 | Object.entries(globalFilters.topics).some( 803 | ([selectedTopic, isSelected]) => 804 | isSelected && repo.topics.includes(selectedTopic), 805 | )) || 806 | (globalFilters.topics?.['all'] ?? false)) && 807 | ((globalFilters.licenseName?.[repo.licenseName] ?? false) || 808 | (globalFilters.licenseName?.['all'] ?? false)) && 809 | (globalFilters.collaboratorsCount 810 | ? (globalFilters.collaboratorsCount?.[0] ?? 0) <= 811 | repo.collaboratorsCount && 812 | repo.collaboratorsCount <= 813 | (globalFilters.collaboratorsCount[1] ?? Infinity) 814 | : true) && 815 | (globalFilters.watchersCount 816 | ? (globalFilters.watchersCount?.[0] ?? 0) <= repo.watchersCount && 817 | repo.watchersCount <= (globalFilters.watchersCount[1] ?? Infinity) 818 | : true) && 819 | (globalFilters.openIssuesCount 820 | ? (globalFilters.openIssuesCount?.[0] ?? 0) <= 821 | repo.openIssuesCount && 822 | repo.openIssuesCount <= 823 | (globalFilters.openIssuesCount[1] ?? Infinity) 824 | : true) && 825 | (globalFilters.closedIssuesCount 826 | ? (globalFilters.closedIssuesCount?.[0] ?? 0) <= 827 | repo.closedIssuesCount && 828 | repo.closedIssuesCount <= 829 | (globalFilters.closedIssuesCount[1] ?? Infinity) 830 | : true) && 831 | (globalFilters.openPullRequestsCount 832 | ? (globalFilters.openPullRequestsCount?.[0] ?? 0) <= 833 | repo.openPullRequestsCount && 834 | repo.openPullRequestsCount <= 835 | (globalFilters.openPullRequestsCount[1] ?? Infinity) 836 | : true) && 837 | (globalFilters.mergedPullRequestsCount 838 | ? (globalFilters.mergedPullRequestsCount?.[0] ?? 0) <= 839 | repo.mergedPullRequestsCount && 840 | repo.mergedPullRequestsCount <= 841 | (globalFilters.mergedPullRequestsCount[1] ?? Infinity) 842 | : true) && 843 | (globalFilters.forksCount 844 | ? (globalFilters.forksCount?.[0] ?? 0) <= repo.forksCount && 845 | repo.forksCount <= (globalFilters.forksCount[1] ?? Infinity) 846 | : true) && 847 | (globalFilters.openIssuesMedianAge 848 | ? testTimeBasedFilter( 849 | globalFilters.openIssuesMedianAge[0], 850 | globalFilters.openIssuesMedianAge[1], 851 | repo.openIssuesMedianAge, 852 | ) 853 | : true) && 854 | (globalFilters.openIssuesAverageAge 855 | ? testTimeBasedFilter( 856 | globalFilters.openIssuesAverageAge[0], 857 | globalFilters.openIssuesAverageAge[1], 858 | repo.openIssuesAverageAge, 859 | ) 860 | : true) && 861 | (globalFilters.closedIssuesMedianAge 862 | ? testTimeBasedFilter( 863 | globalFilters.closedIssuesMedianAge[0], 864 | globalFilters.closedIssuesMedianAge[1], 865 | repo.closedIssuesMedianAge, 866 | ) 867 | : true) && 868 | (globalFilters.closedIssuesAverageAge 869 | ? testTimeBasedFilter( 870 | globalFilters.closedIssuesAverageAge[0], 871 | globalFilters.closedIssuesAverageAge[1], 872 | repo.closedIssuesAverageAge, 873 | ) 874 | : true) && 875 | (globalFilters.issuesResponseMedianAge 876 | ? testTimeBasedFilter( 877 | globalFilters.issuesResponseMedianAge[0], 878 | globalFilters.issuesResponseMedianAge[1], 879 | repo.issuesResponseMedianAge, 880 | ) 881 | : true) && 882 | (globalFilters.issuesResponseAverageAge 883 | ? testTimeBasedFilter( 884 | globalFilters.issuesResponseAverageAge[0], 885 | globalFilters.issuesResponseAverageAge[1], 886 | repo.issuesResponseAverageAge, 887 | ) 888 | : true) 889 | ); 890 | }); 891 | 892 | return result; 893 | }, 894 | [globalFilters], 895 | ); 896 | 897 | const displayRows = filterRepos(sortRepos(repos)); 898 | const createdDate = new Date(Data.meta.createdAt); 899 | 900 | return ( 901 | 902 | 903 | 904 | 905 | 906 | 907 | 908 | 909 | 910 | {subTitle} 911 | 912 | 913 | 914 | Last updated{' '} 915 | 916 | {createdDate.toLocaleDateString()} 917 | {' '} 918 | at{' '} 919 | 920 | {createdDate.toLocaleTimeString()} 921 | 922 | 923 | 924 | 925 | 934 | 942 | 943 | 944 | 945 | 946 | {/* This is a weird hack to make the table fill the page */} 947 | 948 | repo.repositoryName} 952 | defaultColumnOptions={{ 953 | sortable: true, 954 | resizable: true, 955 | }} 956 | sortColumns={sortColumns} 957 | onSortColumnsChange={setSortColumns} 958 | style={{ height: '100%', width: '100%' }} 959 | rowClass={(_, index) => 960 | index % 2 === 1 961 | ? 'bg-slate-100 dark:bg-slate-700 dark:hover:bg-slate-600 hover:bg-slate-200' 962 | : 'hover:bg-slate-200 dark:hover:bg-slate-600' 963 | } 964 | /> 965 | 966 | 967 | 968 | ); 969 | }; 970 | 971 | export default RepositoriesTable; 972 | -------------------------------------------------------------------------------- /app/src/components/TopicCell.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Label } from '@primer/react'; 2 | import { useState } from 'react'; 3 | import { Popover } from 'react-tiny-popover'; 4 | 5 | const TopicCell = ({ 6 | topics, 7 | isSelected, 8 | }: { 9 | topics: string[]; 10 | isSelected: boolean; 11 | }) => { 12 | const [isHovering, setIsHovering] = useState(false); 13 | const isOpen = topics.length > 0 && (isHovering || isSelected); 14 | 15 | return ( 16 | { 19 | return ( 20 | e.stopPropagation()} 23 | sx={{ 24 | backgroundColor: 'Background', 25 | border: '1px solid', 26 | borderColor: 'border.default', 27 | }} 28 | > 29 | {topics.sort().map((topic) => ( 30 | 33 | ))} 34 | 35 | ); 36 | }} 37 | > 38 | setIsHovering(true)} 41 | onMouseLeave={() => setIsHovering(false)} 42 | className="space-x-1 m-1" 43 | > 44 | {topics.sort().map((topic) => ( 45 | 48 | ))} 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default TopicCell; 55 | -------------------------------------------------------------------------------- /app/src/data/.gitignore: -------------------------------------------------------------------------------- 1 | # This is used to create the datafile 2 | 3 | data.json 4 | -------------------------------------------------------------------------------- /app/src/hooks/useIsSSR.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export const useIsSSR = () => { 4 | const [isSSR, setIsSSR] = useState(true); 5 | 6 | useEffect(() => { 7 | setIsSSR(false); 8 | }, []); 9 | 10 | return isSSR; 11 | }; 12 | -------------------------------------------------------------------------------- /app/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider as NextThemeProvider } from 'next-themes'; 2 | import type { AppProps } from 'next/app'; 3 | import '../styles/globals.css'; 4 | 5 | import { 6 | BaseStyles, 7 | ThemeProvider as PrimerThemeProvider, 8 | } from '@primer/react'; 9 | 10 | export default function App({ Component, pageProps }: AppProps) { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import type { DocumentContext, DocumentInitialProps } from 'next/document'; 2 | import Document, { Head, Html, Main, NextScript } from 'next/document'; 3 | import { ServerStyleSheet } from 'styled-components'; 4 | 5 | export default class MyDocument extends Document { 6 | static async getInitialProps( 7 | ctx: DocumentContext, 8 | ): Promise { 9 | const sheet = new ServerStyleSheet(); 10 | const originalRenderPage = ctx.renderPage; 11 | 12 | try { 13 | ctx.renderPage = () => 14 | originalRenderPage({ 15 | enhanceApp: (App) => (props) => 16 | sheet.collectStyles(), 17 | }); 18 | 19 | const initialProps = await Document.getInitialProps(ctx); 20 | return { 21 | ...initialProps, 22 | styles: [initialProps.styles, sheet.getStyleElement()], 23 | }; 24 | } finally { 25 | sheet.seal(); 26 | } 27 | } 28 | 29 | render() { 30 | return ( 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/pages/documentation.tsx: -------------------------------------------------------------------------------- 1 | import Documentation from '@/components/Documentation'; 2 | import { Layout } from '@/components/Layout'; 3 | 4 | export default function DocumentationPage() { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from '@/components/Layout'; 2 | import RepositoriesTable from '@/components/RepositoriesTable'; 3 | 4 | export default function HomePage() { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* add here - see https://github.com/adazzle/react-data-grid/issues/3460#issuecomment-2016837753 */ 6 | @import 'react-data-grid/lib/styles.css'; 7 | -------------------------------------------------------------------------------- /app/src/types/markdown.d.ts: -------------------------------------------------------------------------------- 1 | // declared module for importing markdown files 2 | declare module '*.md' { 3 | const value: string; 4 | export default value; 5 | } 6 | -------------------------------------------------------------------------------- /app/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | module.exports = { 4 | darkMode: 'class', 5 | content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'], 6 | theme: { 7 | transparent: 'transparent', 8 | current: 'currentColor', 9 | extend: { 10 | typography: (theme) => ({ 11 | DEFAULT: { 12 | css: { 13 | maxWidth: '2000px', 14 | code: { 15 | backgroundColor: theme('colors.tremor.background.muted'), 16 | padding: '0.25rem 0.5rem', 17 | borderRadius: theme('borderRadius.tremor-small'), 18 | // Now need to do that for dark mode 19 | }, 20 | 'code::before': { 21 | content: '""', 22 | }, 23 | 'code::after': { 24 | content: '""', 25 | }, 26 | }, 27 | }, 28 | invert: { 29 | css: { 30 | code: { 31 | backgroundColor: theme('colors.dark-tremor.background.muted'), 32 | padding: '0.25rem 0.5rem', 33 | borderRadius: theme('borderRadius.tremor-small'), 34 | }, 35 | }, 36 | }, 37 | }), 38 | height: { 39 | 100: '24rem', 40 | 120: '30rem', 41 | 140: '36rem', 42 | 160: '42rem', 43 | }, 44 | minHeight: (theme) => ({ 45 | ...theme('height'), 46 | }), 47 | maxHeight: (theme) => ({ 48 | ...theme('height'), 49 | }), 50 | width: { 51 | 100: '24rem', 52 | 120: '30rem', 53 | 140: '36rem', 54 | 160: '42rem', 55 | }, 56 | minWidth: (theme) => ({ 57 | ...theme('width'), 58 | }), 59 | maxWidth: (theme) => ({ 60 | ...theme('width'), 61 | }), 62 | colors: { 63 | // light mode 64 | tremor: { 65 | brand: { 66 | faint: '#eff6ff', // blue-50 67 | muted: '#bfdbfe', // blue-200 68 | subtle: '#60a5fa', // blue-400 69 | DEFAULT: '#3b82f6', // blue-500 70 | emphasis: '#1d4ed8', // blue-700 71 | inverted: '#ffffff', // white 72 | }, 73 | background: { 74 | muted: '#f9fafb', // gray-50 75 | subtle: '#f3f4f6', // gray-100 76 | DEFAULT: '#ffffff', // white 77 | emphasis: '#374151', // gray-700 78 | }, 79 | border: { 80 | DEFAULT: '#e5e7eb', // gray-200 81 | }, 82 | ring: { 83 | DEFAULT: '#e5e7eb', // gray-200 84 | }, 85 | content: { 86 | subtle: '#9ca3af', // gray-400 87 | DEFAULT: '#6b7280', // gray-500 88 | emphasis: '#374151', // gray-700 89 | strong: '#111827', // gray-900 90 | inverted: '#ffffff', // white 91 | }, 92 | }, 93 | // dark mode 94 | 'dark-tremor': { 95 | brand: { 96 | faint: '#0B1229', // custom 97 | muted: '#172554', // blue-950 98 | subtle: '#1e40af', // blue-800 99 | DEFAULT: '#3b82f6', // blue-500 100 | emphasis: '#60a5fa', // blue-400 101 | inverted: '#030712', // gray-950 102 | }, 103 | background: { 104 | muted: '#131A2B', // custom 105 | subtle: '#1f2937', // gray-800 106 | DEFAULT: '#111827', // gray-900 107 | emphasis: '#d1d5db', // gray-300 108 | }, 109 | border: { 110 | DEFAULT: '#1f2937', // gray-800 111 | }, 112 | ring: { 113 | DEFAULT: '#1f2937', // gray-800 114 | }, 115 | content: { 116 | subtle: '#4b5563', // gray-600 117 | DEFAULT: '#6b7280', // gray-600 118 | emphasis: '#e5e7eb', // gray-200 119 | strong: '#f9fafb', // gray-50 120 | inverted: '#000000', // black 121 | }, 122 | }, 123 | }, 124 | boxShadow: { 125 | // light 126 | 'tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', 127 | 'tremor-card': 128 | '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', 129 | 'tremor-dropdown': 130 | '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', 131 | // dark 132 | 'dark-tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)', 133 | 'dark-tremor-card': 134 | '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', 135 | 'dark-tremor-dropdown': 136 | '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', 137 | }, 138 | borderRadius: { 139 | 'tremor-small': '0.375rem', 140 | 'tremor-default': '0.5rem', 141 | 'tremor-full': '9999px', 142 | }, 143 | fontSize: { 144 | 'tremor-label': ['0.75rem'], 145 | 'tremor-default': ['0.875rem', { lineHeight: '1.25rem' }], 146 | 'tremor-title': ['1.125rem', { lineHeight: '1.75rem' }], 147 | 'tremor-metric': ['1.875rem', { lineHeight: '2.25rem' }], 148 | }, 149 | }, 150 | }, 151 | safelist: [ 152 | { 153 | pattern: 154 | /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 155 | variants: ['hover', 'ui-selected'], 156 | }, 157 | { 158 | pattern: 159 | /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 160 | variants: ['hover', 'ui-selected'], 161 | }, 162 | { 163 | pattern: 164 | /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 165 | variants: ['hover', 'ui-selected'], 166 | }, 167 | { 168 | pattern: 169 | /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 170 | }, 171 | { 172 | pattern: 173 | /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 174 | }, 175 | { 176 | pattern: 177 | /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 178 | }, 179 | ], 180 | plugins: [require('@tailwindcss/typography')], 181 | }; 182 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./src/*"] 19 | }, 20 | "baseUrl": "./", 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ] 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github-community-projects/org-metrics-dashboard/2fedffcdf769f29bc6e1732f494a30259d56f15f/assets/preview.png -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .idea 4 | *.log 5 | *.tgz 6 | coverage 7 | dist 8 | lib-cov 9 | logs 10 | node_modules 11 | temp 12 | *data.json -------------------------------------------------------------------------------- /backend/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild'; 2 | 3 | export default defineBuildConfig({ 4 | entries: ['src/index'], 5 | declaration: true, 6 | clean: true, 7 | rollup: { 8 | emitCJS: true, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.0", 6 | "description": "_description_", 7 | "license": "MIT", 8 | "keywords": [], 9 | "sideEffects": false, 10 | "exports": { 11 | ".": { 12 | "types": "./dist/index.d.ts", 13 | "import": "./dist/index.mjs", 14 | "require": "./dist/index.cjs" 15 | } 16 | }, 17 | "main": "./dist/index.mjs", 18 | "module": "./dist/index.mjs", 19 | "types": "./dist/index.d.ts", 20 | "typesVersions": { 21 | "*": { 22 | "*": [ 23 | "./dist/*", 24 | "./dist/index.d.ts" 25 | ] 26 | } 27 | }, 28 | "files": [ 29 | "dist" 30 | ], 31 | "scripts": { 32 | "build": "unbuild", 33 | "stub": "unbuild --stub", 34 | "lint": "eslint .", 35 | "dev": "esno src/index.ts", 36 | "test": "vitest", 37 | "typecheck": "tsc --noEmit" 38 | }, 39 | "devDependencies": { 40 | "@types/fs-extra": "^11.0.4", 41 | "@types/node": "^20.10.7", 42 | "bumpp": "^9.9.0", 43 | "eslint": "^8.56.0", 44 | "lint-staged": "^15.2.10", 45 | "rimraf": "^5.0.5", 46 | "typescript": "^5.7.2", 47 | "unbuild": "^2.0.0", 48 | "vite": "^5.4.14", 49 | "vitest": "^1.6.1" 50 | }, 51 | "dependencies": { 52 | "@octokit/graphql-schema": "^14.50.0", 53 | "@octokit/plugin-paginate-graphql": "^4.0.0", 54 | "@octokit/plugin-retry": "^6.0.1", 55 | "@octokit/plugin-throttling": "^8.1.3", 56 | "@octokit/rest": "^20.0.2", 57 | "dotenv": "^16.4.7", 58 | "esno": "^4.8.0", 59 | "fs-extra": "^11.2.0", 60 | "octokit": "^3.1.2", 61 | "yaml": "^2.6.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /backend/src/fetchers/discussions.ts: -------------------------------------------------------------------------------- 1 | // Fetchers for issue & pull request data and metrics 2 | 3 | import { Organization } from '@octokit/graphql-schema'; 4 | import { Config, Fetcher } from '..'; 5 | import { CustomOctokit } from '../lib/octokit'; 6 | 7 | const queryForDiscussions = async (octokit: CustomOctokit, config: Config) => { 8 | return await octokit.graphql.paginate<{ organization: Organization }>( 9 | ` 10 | query($cursor: String, $organization: String!) { 11 | organization(login:$organization){ 12 | repositories(privacy:PUBLIC, first:100, isFork:false, isArchived:false, after: $cursor) { 13 | totalCount 14 | pageInfo { 15 | hasNextPage 16 | endCursor 17 | } 18 | nodes { 19 | name 20 | discussions { 21 | totalCount 22 | } 23 | } 24 | } 25 | } 26 | } 27 | `, 28 | { 29 | organization: config.organization, 30 | }, 31 | ); 32 | }; 33 | 34 | const getDiscussionData = async (octokit: CustomOctokit, config: Config) => { 35 | const queryResult = await queryForDiscussions(octokit, config); 36 | 37 | const dataResult = queryResult.organization.repositories.nodes?.map( 38 | (node) => { 39 | return { 40 | repositoryName: node!.name, 41 | discussionsCount: node!.discussions.totalCount, 42 | }; 43 | }, 44 | ); 45 | 46 | return dataResult; 47 | }; 48 | 49 | export const addDiscussionData: Fetcher = async (result, octokit, config) => { 50 | const dataResult = await getDiscussionData(octokit, config); 51 | if (!dataResult) { 52 | return result; 53 | } 54 | 55 | for (const repo of dataResult) { 56 | result.repositories[repo.repositoryName].discussionsCount = 57 | repo.discussionsCount; 58 | } 59 | 60 | return result; 61 | }; 62 | -------------------------------------------------------------------------------- /backend/src/fetchers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './discussions'; 2 | export * from './issues'; 3 | export * from './meta'; 4 | export * from './organization'; 5 | export * from './repository'; 6 | -------------------------------------------------------------------------------- /backend/src/fetchers/issues.ts: -------------------------------------------------------------------------------- 1 | // Fetchers for issue & pull request data and metrics 2 | 3 | import { 4 | IssueConnection, 5 | PageInfo, 6 | PullRequestConnection, 7 | Repository, 8 | RepositoryConnection, 9 | } from '@octokit/graphql-schema'; 10 | import { Config, Fetcher } from '..'; 11 | import { CustomOctokit } from '../lib/octokit'; 12 | 13 | const getIssueAndPrData = async (octokit: CustomOctokit, config: Config) => { 14 | const issueData = await octokit.graphql.paginate<{ 15 | organization: { 16 | repositories: { 17 | totalCount: RepositoryConnection['totalCount']; 18 | pageInfo: PageInfo; 19 | nodes: { 20 | name: Repository['name']; 21 | totalIssues: IssueConnection; 22 | openIssues: IssueConnection; 23 | closedIssues: IssueConnection; 24 | totalPullRequests: PullRequestConnection; 25 | openPullRequests: PullRequestConnection; 26 | closedPullRequests: PullRequestConnection; 27 | mergedPullRequests: PullRequestConnection; 28 | }[]; 29 | }; 30 | }; 31 | }>( 32 | ` 33 | query($cursor: String, $organization: String!) { 34 | organization(login:$organization){ 35 | repositories(privacy:PUBLIC, first:100, isFork:false, isArchived:false, after: $cursor) { 36 | totalCount 37 | pageInfo { 38 | hasNextPage 39 | endCursor 40 | } 41 | nodes { 42 | name 43 | totalIssues: issues { 44 | totalCount 45 | } 46 | closedIssues: issues(states:CLOSED) { 47 | totalCount 48 | } 49 | openIssues: issues(states:OPEN) { 50 | totalCount 51 | } 52 | openPullRequests: pullRequests(states:OPEN) { 53 | totalCount 54 | } 55 | totalPullRequests: pullRequests { 56 | totalCount 57 | } 58 | closedPullRequests: pullRequests(states:CLOSED) { 59 | totalCount 60 | } 61 | mergedPullRequests: pullRequests(states:MERGED) { 62 | totalCount 63 | } 64 | } 65 | } 66 | } 67 | } 68 | `, 69 | { 70 | organization: config.organization, 71 | }, 72 | ); 73 | 74 | return issueData; 75 | }; 76 | 77 | export const addIssueAndPrData: Fetcher = async (result, octokit, config) => { 78 | const dataResult = await getIssueAndPrData(octokit, config); 79 | dataResult.organization.repositories.nodes.forEach((repo) => { 80 | result.repositories[repo.name] = { 81 | ...result.repositories[repo.name], 82 | totalIssuesCount: repo.totalIssues.totalCount, 83 | openIssuesCount: repo.openIssues.totalCount, 84 | closedIssuesCount: repo.closedIssues.totalCount, 85 | totalPullRequestsCount: repo.totalPullRequests.totalCount, 86 | openPullRequestsCount: repo.openPullRequests.totalCount, 87 | closedPullRequestsCount: repo.closedPullRequests.totalCount, 88 | mergedPullRequestsCount: repo.mergedPullRequests.totalCount, 89 | }; 90 | }); 91 | 92 | return result; 93 | }; 94 | 95 | const calculateIssueMetricsPerRepo = async ( 96 | repoName: string, 97 | state: 'open' | 'closed', 98 | octokit: CustomOctokit, 99 | config: Config, 100 | ) => { 101 | const result = await octokit.paginate(octokit.issues.listForRepo, { 102 | owner: config.organization, 103 | repo: repoName, 104 | state: state, 105 | // Need to limit this query somehow, otherwise it will take forever/timeout 106 | since: config.since, 107 | }); 108 | 109 | // Calculate the total age of open issues 110 | const issues = result.filter((issue) => !issue.pull_request); 111 | const issuesCount = issues.length; 112 | const issuesTotalAge = issues.reduce((acc, issue) => { 113 | const createdAt = new Date(issue.created_at); 114 | const now = new Date(); 115 | const age = now.getTime() - createdAt.getTime(); 116 | return acc + age; 117 | }, 0); 118 | 119 | // Calculate the age of open issues 120 | const issuesAverageAge = issuesCount > 0 ? issuesTotalAge / issuesCount : 0; 121 | const issuesMedianAge = 122 | issues.length > 0 123 | ? new Date().getTime() - 124 | new Date(issues[Math.floor(issues.length / 2)].created_at).getTime() 125 | : 0; 126 | 127 | return { 128 | issuesCount, 129 | issuesAverageAge, 130 | issuesMedianAge, 131 | }; 132 | }; 133 | 134 | const calculateIssueResponseTime = async ( 135 | repoName: string, 136 | octokit: CustomOctokit, 137 | config: Config, 138 | ) => { 139 | const result = await octokit.graphql.paginate<{ repository: Repository }>( 140 | ` 141 | query ($cursor: String, $organization: String!, $repoName: String!, $since: DateTime!) { 142 | repository(owner: $organization, name:$repoName) { 143 | issues(first: 100, after: $cursor, filterBy: {since: $since}) { 144 | pageInfo { 145 | hasNextPage 146 | endCursor 147 | } 148 | nodes { 149 | author { 150 | login 151 | } 152 | createdAt 153 | comments(first: 30) { 154 | totalCount 155 | nodes { 156 | createdAt 157 | author { 158 | __typename 159 | login 160 | ... on Bot { 161 | id 162 | } 163 | } 164 | isMinimized 165 | } 166 | } 167 | } 168 | } 169 | } 170 | } 171 | `, 172 | { 173 | organization: config.organization, 174 | repoName: repoName, 175 | since: config.since, 176 | }, 177 | ); 178 | 179 | // Check if there are any issues at all 180 | if ( 181 | !result.repository || 182 | !result.repository.issues.nodes || 183 | result.repository.issues.nodes?.length === 0 184 | ) { 185 | return { 186 | issuesCount: 0, 187 | issuesResponseAverageAge: 0, 188 | issuesResponseMedianAge: 0, 189 | }; 190 | } 191 | 192 | // Filter out issues without comments that meet our criteria 193 | // Criteria: 194 | // - not the author of the issue 195 | // - the comment is not a bot 196 | // - the comment is not marked as spam 197 | // 198 | // Also filter out issues without comments after we filtered the comments 199 | const issues = result.repository.issues.nodes 200 | .map((issue) => { 201 | return { 202 | ...issue, 203 | comments: { 204 | nodes: issue!.comments.nodes?.filter( 205 | (comment) => 206 | comment!.author?.login !== issue!.author?.login && 207 | comment!.author?.__typename !== 'Bot' && 208 | !comment?.isMinimized, 209 | ), 210 | }, 211 | }; 212 | }) 213 | .filter((issue) => issue!.comments?.nodes?.length ?? 0 > 0); 214 | 215 | const issuesCount = issues.length; 216 | 217 | // Calculate the response time for each issue 218 | const issuesResponseTime = issues.map((issue) => { 219 | const createdAt = new Date(issue!.createdAt); 220 | const firstCommentAt = new Date(issue!.comments!.nodes?.[0]!.createdAt); 221 | return firstCommentAt.getTime() - createdAt.getTime(); 222 | }); 223 | 224 | // Sort them based on response time 225 | issuesResponseTime.sort((a, b) => a - b); 226 | 227 | // Calculate the average 228 | const issuesTotalResponseTime = issuesResponseTime.reduce( 229 | (acc, responseTime) => acc + responseTime, 230 | 0, 231 | ); 232 | const issuesResponseAverageAge = 233 | issuesCount > 0 ? issuesTotalResponseTime / issuesCount : 0; 234 | 235 | // Calculate the median 236 | const issuesResponseMedianAge = 237 | issues.length > 0 ? issuesResponseTime[Math.floor(issues.length / 2)] : 0; 238 | 239 | return { 240 | issuesCount, 241 | issuesResponseAverageAge, 242 | issuesResponseMedianAge, 243 | }; 244 | }; 245 | 246 | export const addIssueMetricsData: Fetcher = async (result, octokit, config) => { 247 | for (const repoName of Object.keys(result.repositories)) { 248 | const { 249 | issuesAverageAge: openIssuesAverageAge, 250 | issuesMedianAge: openIssuesMedianAge, 251 | } = await calculateIssueMetricsPerRepo(repoName, 'open', octokit, config); 252 | 253 | const { 254 | issuesAverageAge: closedIssuesAverageAge, 255 | issuesMedianAge: closedIssuesMedianAge, 256 | } = await calculateIssueMetricsPerRepo(repoName, 'closed', octokit, config); 257 | 258 | const { issuesResponseAverageAge, issuesResponseMedianAge } = 259 | await calculateIssueResponseTime(repoName, octokit, config); 260 | 261 | const repo = result.repositories[repoName]; 262 | repo.openIssuesAverageAge = openIssuesAverageAge; 263 | repo.openIssuesMedianAge = openIssuesMedianAge; 264 | repo.closedIssuesAverageAge = closedIssuesAverageAge; 265 | repo.closedIssuesMedianAge = closedIssuesMedianAge; 266 | repo.issuesResponseAverageAge = issuesResponseAverageAge; 267 | repo.issuesResponseMedianAge = issuesResponseMedianAge; 268 | } 269 | 270 | return result; 271 | }; 272 | -------------------------------------------------------------------------------- /backend/src/fetchers/meta.ts: -------------------------------------------------------------------------------- 1 | // Fetchers for meta data about this run 2 | 3 | import { Fetcher } from '..'; 4 | 5 | export const addMetaToResult: Fetcher = async (result) => { 6 | return { 7 | ...result, 8 | meta: { 9 | createdAt: new Date().toISOString(), 10 | }, 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /backend/src/fetchers/organization.ts: -------------------------------------------------------------------------------- 1 | // Fetchers for organization data and metrics 2 | 3 | import { Fetcher } from '..'; 4 | 5 | export const addOrganizationInfoToResult: Fetcher = async ( 6 | result, 7 | octokit, 8 | config, 9 | ) => { 10 | const organization = await octokit.orgs.get({ org: config.organization }); 11 | 12 | return { 13 | ...result, 14 | orgInfo: { 15 | login: organization.data.login, 16 | name: organization.data.name ?? organization.data.login, 17 | description: organization.data.description, 18 | createdAt: organization.data.created_at, 19 | repositoriesCount: organization.data.public_repos, 20 | }, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /backend/src/fetchers/repository.ts: -------------------------------------------------------------------------------- 1 | // Fetchers for repository data and metrics 2 | 3 | import { Organization, Repository } from '@octokit/graphql-schema'; 4 | import { Fetcher } from '..'; 5 | import { RepositoryResult } from '../../../types'; 6 | 7 | export const addRepositoriesToResult: Fetcher = async ( 8 | result, 9 | octokit, 10 | config, 11 | ) => { 12 | const organization = await octokit.graphql.paginate<{ 13 | organization: Organization; 14 | }>( 15 | ` 16 | query ($cursor: String, $organization: String!) { 17 | organization(login:$organization) { 18 | repositories(privacy:PUBLIC, first:100, isFork:false, isArchived:false, after: $cursor) 19 | { 20 | pageInfo { 21 | hasNextPage 22 | endCursor 23 | } 24 | nodes { 25 | name 26 | nameWithOwner 27 | forkCount 28 | stargazerCount 29 | isFork 30 | isArchived 31 | hasIssuesEnabled 32 | hasProjectsEnabled 33 | hasDiscussionsEnabled 34 | projects { 35 | totalCount 36 | } 37 | projectsV2 { 38 | totalCount 39 | } 40 | discussions { 41 | totalCount 42 | } 43 | licenseInfo { 44 | name 45 | } 46 | watchers { 47 | totalCount 48 | } 49 | collaborators { 50 | totalCount 51 | } 52 | repositoryTopics(first: 20) { 53 | nodes { 54 | topic { 55 | name 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | `, 64 | { 65 | organization: config.organization, 66 | }, 67 | ); 68 | 69 | const filteredRepos = organization.organization.repositories.nodes!.filter( 70 | (repo) => 71 | !(repo?.isArchived && !config.includeArchived) || 72 | !(repo.isFork && !config.includeForks), 73 | ) as Repository[]; 74 | 75 | return { 76 | ...result, 77 | repositories: filteredRepos.reduce( 78 | (acc, repo) => { 79 | return { 80 | ...acc, 81 | [repo.name]: { 82 | repositoryName: repo.name, 83 | repoNameWithOwner: repo.nameWithOwner, 84 | licenseName: repo.licenseInfo?.name || 'No License', 85 | topics: repo.repositoryTopics.nodes?.map( 86 | (node) => node?.topic.name, 87 | ), 88 | forksCount: repo.forkCount, 89 | watchersCount: repo.watchers.totalCount, 90 | starsCount: repo.stargazerCount, 91 | issuesEnabled: repo.hasIssuesEnabled, 92 | projectsEnabled: repo.hasProjectsEnabled, 93 | discussionsEnabled: repo.hasDiscussionsEnabled, 94 | collaboratorsCount: repo.collaborators?.totalCount || 0, 95 | projectsCount: repo.projects.totalCount, 96 | projectsV2Count: repo.projectsV2.totalCount, 97 | } as RepositoryResult, 98 | }; 99 | }, 100 | {} as Record, 101 | ), 102 | }; 103 | }; 104 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import fs from 'fs-extra'; 3 | import { dirname, resolve } from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | import { parse } from 'yaml'; 6 | import { RepositoryResult } from '../../types'; 7 | import { 8 | addDiscussionData, 9 | addIssueAndPrData, 10 | addIssueMetricsData, 11 | addMetaToResult, 12 | addOrganizationInfoToResult, 13 | addRepositoriesToResult, 14 | } from './fetchers'; 15 | import { CustomOctokit, checkRateLimit, personalOctokit } from './lib/octokit'; 16 | 17 | export interface Result { 18 | meta: { 19 | createdAt: string; 20 | }; 21 | orgInfo: { 22 | login: string; 23 | name?: string; 24 | description: string | null; 25 | createdAt: string; 26 | repositoriesCount: number; 27 | // "membersWithRoleCount": number; 28 | // "projectsCount": number; 29 | // "projectsV2Count": number; 30 | // "teamsCount": number; 31 | }; 32 | repositories: Record; 33 | } 34 | 35 | export type Fetcher = ( 36 | result: Result, 37 | octokit: CustomOctokit, 38 | config: Config, 39 | ) => Promise | Result; 40 | 41 | export interface Config { 42 | organization: string; 43 | includeForks?: boolean; 44 | includeArchived?: boolean; 45 | since?: string; // Used for limiting the date range of items to fetch 46 | } 47 | 48 | // Check for the GRAPHQL_TOKEN environment variable 49 | if (!process.env.GRAPHQL_TOKEN) { 50 | console.log('GRAPHQL_TOKEN environment variable is required, exiting...'); 51 | throw new Error('GRAPHQL_TOKEN environment variable is required!'); 52 | } 53 | 54 | console.log('Starting GitHub organization metrics fetcher'); 55 | console.log('🔑 Authenticating with GitHub'); 56 | 57 | const octokit = personalOctokit(process.env.GRAPHQL_TOKEN!); 58 | 59 | // Read in configuration for the fetchers 60 | let yamlConfig: Partial = {}; 61 | const __filename = fileURLToPath(import.meta.url); 62 | const __dirname = dirname(__filename); 63 | const configFileLocation = resolve(__dirname, '../../config.yml'); 64 | try { 65 | const configFile = fs.readFileSync(configFileLocation, 'utf-8'); 66 | yamlConfig = parse(configFile) as Partial; 67 | } catch (e) { 68 | console.error('Error reading config file at', configFileLocation); 69 | console.log(e); 70 | } 71 | 72 | const envOrganizationName = process.env.ORGANIZATION_NAME; 73 | const configOrganizationName = yamlConfig.organization; 74 | 75 | if (!envOrganizationName && !configOrganizationName) { 76 | console.log( 77 | 'ORGANIZATION_NAME environment variable or `organization` in config.yml is required', 78 | ); 79 | throw new Error( 80 | 'ORGANIZATION_NAME environment variable or `organization` in config.yml is required, exiting...', 81 | ); 82 | } 83 | 84 | const config: Config = { 85 | includeForks: false, 86 | includeArchived: false, 87 | ...yamlConfig, 88 | // You can override the organization in an env variable ORGANIZATION_NAME 89 | organization: 90 | (envOrganizationName && envOrganizationName?.length !== 0 91 | ? envOrganizationName 92 | : configOrganizationName) ?? '', 93 | // Default since date is 365 days ago (1 year) 94 | since: yamlConfig.since 95 | ? new Date(yamlConfig.since).toISOString() 96 | : new Date(Date.now() - 365 * (24 * 60 * 60 * 1000)).toISOString(), 97 | }; 98 | 99 | console.log(`📋 Configuration: \n${JSON.stringify(config, null, 2)}`); 100 | 101 | const pipeline = 102 | (octokit: CustomOctokit, config: Config) => 103 | async (...fetchers: Fetcher[]) => { 104 | let result = {} as Result; 105 | 106 | for (const fetcher of fetchers) { 107 | console.log(`🔧 Running fetcher ${fetcher.name}`); 108 | result = await fetcher(result, octokit, config); 109 | console.log(`✨ Finished ${fetcher.name}`); 110 | const res = await checkRateLimit(octokit); 111 | console.log( 112 | `⚙️ Rate limit: ${res.remaining}/${ 113 | res.limit 114 | } remaining until ${res.resetDate.toLocaleString()}`, 115 | ); 116 | } 117 | 118 | return result; 119 | }; 120 | 121 | const outputResult = async (result: Result) => { 122 | const destination = '../app/src/data/data.json'; 123 | fs.outputJSONSync(destination, result, { spaces: 2 }); 124 | console.log(`📦 Wrote result to ${destination}`); 125 | }; 126 | 127 | const result = await pipeline(octokit, config)( 128 | addMetaToResult, 129 | addOrganizationInfoToResult, 130 | addRepositoriesToResult, 131 | addIssueAndPrData, 132 | addDiscussionData, 133 | addIssueMetricsData, 134 | ); 135 | 136 | outputResult(result); 137 | -------------------------------------------------------------------------------- /backend/src/lib/octokit.ts: -------------------------------------------------------------------------------- 1 | import { paginateGraphql } from '@octokit/plugin-paginate-graphql'; 2 | import { retry } from '@octokit/plugin-retry'; 3 | import { throttling } from '@octokit/plugin-throttling'; 4 | import { Octokit } from '@octokit/rest'; 5 | 6 | /** 7 | * Creates a new octokit instance that is authenticated as the user 8 | * @param token personal access token 9 | * @returns Octokit authorized with the personal access token 10 | */ 11 | export const personalOctokit = (token: string) => { 12 | // Not sure if plugin order matters 13 | const ModifiedOctokit = Octokit.plugin(paginateGraphql, retry, throttling); 14 | return new ModifiedOctokit({ 15 | auth: token, 16 | throttle: { 17 | onRateLimit: (retryAfter, options, octokit, retryCount) => { 18 | octokit.log.warn( 19 | `Request quota exhausted for request ${options.method} ${options.url} - retrying in ${retryAfter} seconds`, 20 | ); 21 | 22 | if (retryCount < 1) { 23 | // only retries once 24 | octokit.log.info(`Retry attempt ${retryCount + 1}, retrying...`); 25 | return true; 26 | } 27 | }, 28 | onSecondaryRateLimit: (retryAfter, options, octokit) => { 29 | // does not retry, only logs a warning 30 | octokit.log.warn( 31 | `SecondaryRateLimit detected for request ${options.method} ${options.url}`, 32 | ); 33 | }, 34 | }, 35 | }); 36 | }; 37 | 38 | export const checkRateLimit = async (octokit: CustomOctokit) => { 39 | const rateLimit = await octokit.rateLimit.get(); 40 | const { 41 | core: { limit, remaining, reset }, 42 | } = rateLimit.data.resources; 43 | const resetDate = new Date(reset * 1000); 44 | 45 | return { 46 | limit, 47 | remaining, 48 | reset, 49 | resetDate, 50 | }; 51 | }; 52 | 53 | export type CustomOctokit = ReturnType; 54 | -------------------------------------------------------------------------------- /backend/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | describe('should', () => { 4 | it('exported', () => { 5 | expect(1).toEqual(1); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["ESNext"], 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "esModuleInterop": true, 11 | "skipDefaultLibCheck": true, 12 | "skipLibCheck": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Organization to pull metrics from 3 | organization: 'github-community-projects' 4 | 5 | # Start date to pull metrics from 6 | since: '2023-01-01' 7 | 8 | # GitHub Pages path to the repository 9 | # Used for handling relative paths for assets 10 | basePath: '/org-metrics-dashboard' 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "metrics", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "dotenvx run -- concurrently -n backend,app -c auto \"cd backend && npm run dev\" \"cd app && npm run dev\"", 6 | "dev:app": "dotenvx run -- npm run dev --workspace=app", 7 | "dev:backend": "dotenvx run -- npm run dev --workspace=backend" 8 | }, 9 | "keywords": [], 10 | "author": "GitHub OSPO ", 11 | "license": "MIT", 12 | "workspaces": [ 13 | "./app", 14 | "./backend" 15 | ], 16 | "devDependencies": { 17 | "@dotenvx/dotenvx": "^0.26.0", 18 | "concurrently": "^8.2.2", 19 | "prettier": "^3.4.2", 20 | "yaml": "^2.6.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export interface RepositoryResult { 2 | // Repo metadata 3 | repositoryName: string; 4 | repoNameWithOwner: string; 5 | licenseName: string; 6 | topics: string[]; 7 | 8 | // Counts of various things 9 | projectsCount: number; 10 | projectsV2Count: number; 11 | discussionsCount: number; 12 | forksCount: number; 13 | totalIssuesCount: number; 14 | openIssuesCount: number; 15 | closedIssuesCount: number; 16 | totalPullRequestsCount: number; 17 | openPullRequestsCount: number; 18 | closedPullRequestsCount: number; 19 | mergedPullRequestsCount: number; 20 | watchersCount: number; 21 | starsCount: number; 22 | collaboratorsCount: number; 23 | 24 | // Flags 25 | discussionsEnabled: boolean; 26 | projectsEnabled: boolean; 27 | issuesEnabled: boolean; 28 | 29 | // Calculated metrics 30 | openIssuesAverageAge: number; 31 | openIssuesMedianAge: number; 32 | closedIssuesAverageAge: number; 33 | closedIssuesMedianAge: number; 34 | issuesResponseAverageAge: number; 35 | issuesResponseMedianAge: number; 36 | } 37 | --------------------------------------------------------------------------------