├── server ├── __init__.py ├── temp │ └── __init__.py ├── runtime.txt ├── Procfile ├── static │ ├── img │ │ ├── check-icon.png │ │ ├── cross-icon.png │ │ ├── extra-logo.png │ │ ├── main-logo.png │ │ └── email-header.gif │ └── css │ │ └── report.css ├── templates │ ├── redirect.html │ └── login.html ├── settings.toml ├── main.py ├── requirements.txt ├── Dockerfile ├── alembic │ ├── README │ ├── script.py.mako │ ├── versions │ │ ├── ba474f47d3f2_initial_migration.py │ │ └── 9aa6d5120b0b_columns_for_additional_statistic_added.py │ └── env.py ├── fly.toml ├── config.py ├── db.py ├── app.py ├── utils │ └── sender.py ├── alembic.ini ├── routes.py └── models.py ├── chrome-extension ├── .eslintignore ├── public │ ├── favicon.ico │ ├── images │ │ ├── icon128.png │ │ ├── icon16.png │ │ ├── icon48.png │ │ ├── popup-header.gif │ │ └── repo-banner.gif │ └── manifest.json ├── src │ ├── features │ │ ├── env.ts │ │ ├── constants.ts │ │ ├── utils │ │ │ ├── timeout.ts │ │ │ ├── getYearMonth.ts │ │ │ ├── octokitRepoUrl.ts │ │ │ ├── createName.ts │ │ │ ├── asyncForEach.ts │ │ │ ├── getOctokitRepoData.ts │ │ │ ├── getLocation.ts │ │ │ ├── initUrl.ts │ │ │ ├── getOwnTabs.ts │ │ │ ├── initToken.ts │ │ │ ├── getPullRequestStatistic.ts │ │ │ ├── index.ts │ │ │ ├── getKeysForStatisticPeriod.ts │ │ │ ├── loadFromHistory.ts │ │ │ ├── popupCenter.ts │ │ │ ├── groupStarsHistoryByMonth.ts │ │ │ ├── calculateTotalRating.ts │ │ │ ├── getIssuesStatistic.ts │ │ │ └── serializeUser.ts │ │ ├── octokit.ts │ │ ├── queries │ │ │ ├── index.ts │ │ │ ├── starHistoryQuery.ts │ │ │ ├── repositoryQuery.ts │ │ │ ├── issuesQuery.ts │ │ │ ├── pullRequestsQuery.ts │ │ │ ├── getStargazersQuery.ts │ │ │ └── getForkersQuery.ts │ │ ├── gql │ │ │ └── queries │ │ │ │ ├── index.ts │ │ │ │ ├── repositoryQuery.ts │ │ │ │ ├── starHistoryQuery.ts │ │ │ │ ├── issuesQuery.ts │ │ │ │ ├── pullRequestsQuery.ts │ │ │ │ ├── getStargazersQuery.ts │ │ │ │ └── getForkersQuery.ts │ │ ├── api.ts │ │ ├── store │ │ │ ├── history.ts │ │ │ ├── models.ts │ │ │ ├── inspectData.ts │ │ │ ├── downloader.ts │ │ │ └── notification.ts │ │ ├── authentication.ts │ │ └── repoInspector.ts │ ├── assets │ │ └── scss │ │ │ ├── transition.scss │ │ │ ├── alerts.scss │ │ │ └── custom.scss │ ├── entry │ │ ├── background.ts │ │ ├── popup.ts │ │ └── options.ts │ ├── view │ │ ├── components │ │ │ ├── StatisticChart.vue │ │ │ ├── DownloadCard.vue │ │ │ ├── TotalRatingWeightsSettings.vue │ │ │ └── HistoryCard.vue │ │ ├── options.vue │ │ └── popup.vue │ └── global.d.ts ├── .prettierrc.cjs ├── graphql.config.yml ├── README.md ├── popup.html ├── options.html ├── background.html ├── .vscode │ └── settings.json ├── tsconfig.json ├── package.json ├── vite.config.js └── .eslintrc ├── .vscode └── launch.json ├── README.md └── .gitignore /server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/temp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.10.7 -------------------------------------------------------------------------------- /server/Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn -k uvicorn.workers.UvicornWorker main:app -------------------------------------------------------------------------------- /chrome-extension/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | public/ 3 | dist/ 4 | src/assets/ 5 | vite.config.js 6 | graphql.schema.ts 7 | -------------------------------------------------------------------------------- /server/static/img/check-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/server/static/img/check-icon.png -------------------------------------------------------------------------------- /server/static/img/cross-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/server/static/img/cross-icon.png -------------------------------------------------------------------------------- /server/static/img/extra-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/server/static/img/extra-logo.png -------------------------------------------------------------------------------- /server/static/img/main-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/server/static/img/main-logo.png -------------------------------------------------------------------------------- /chrome-extension/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/chrome-extension/public/favicon.ico -------------------------------------------------------------------------------- /chrome-extension/src/features/env.ts: -------------------------------------------------------------------------------- 1 | const settings = { 2 | rollbarAccessToken: '', 3 | }; 4 | 5 | export default settings; 6 | -------------------------------------------------------------------------------- /server/static/img/email-header.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/server/static/img/email-header.gif -------------------------------------------------------------------------------- /chrome-extension/src/features/constants.ts: -------------------------------------------------------------------------------- 1 | export const USERS_QUERY_LIMIT = 20; 2 | 3 | export const MINIMUM_REQUEST_LIMIT_AMOUNT = 10; 4 | -------------------------------------------------------------------------------- /chrome-extension/public/images/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/chrome-extension/public/images/icon128.png -------------------------------------------------------------------------------- /chrome-extension/public/images/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/chrome-extension/public/images/icon16.png -------------------------------------------------------------------------------- /chrome-extension/public/images/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/chrome-extension/public/images/icon48.png -------------------------------------------------------------------------------- /chrome-extension/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | }; 7 | -------------------------------------------------------------------------------- /server/templates/redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /chrome-extension/public/images/popup-header.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/chrome-extension/public/images/popup-header.gif -------------------------------------------------------------------------------- /chrome-extension/public/images/repo-banner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HetzVentures/repoInspector/HEAD/chrome-extension/public/images/repo-banner.gif -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/timeout.ts: -------------------------------------------------------------------------------- 1 | export const timeout = (ms: number) => 2 | new Promise((resolve) => { 3 | setTimeout(resolve, ms); 4 | }); 5 | -------------------------------------------------------------------------------- /server/settings.toml: -------------------------------------------------------------------------------- 1 | [development] 2 | dynaconf_merge = true 3 | 4 | [development.db] 5 | echo = true 6 | 7 | [production] 8 | dynaconf_merge = true 9 | 10 | 11 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/getYearMonth.ts: -------------------------------------------------------------------------------- 1 | export const getYearMonth = (date: Date): string => 2 | `${date.getFullYear()}.${(date.getMonth() + 1).toString().padStart(2, '0')}`; 3 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/octokitRepoUrl.ts: -------------------------------------------------------------------------------- 1 | import { createName } from './createName'; 2 | 3 | export const octokitRepoUrl = (repo: string) => 4 | // get repo name for octokit 5 | `/repos/${createName(repo)}`; 6 | -------------------------------------------------------------------------------- /server/main.py: -------------------------------------------------------------------------------- 1 | from routes import repository_router, login_router 2 | from app import app 3 | 4 | app.include_router(repository_router, tags=["repositories"], prefix="/repository") 5 | app.include_router(login_router, prefix="/login") 6 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/createName.ts: -------------------------------------------------------------------------------- 1 | export const createName = (repo: string) => { 2 | // remove any parts of url beyond repo name 3 | const urlParts = repo.split('/'); 4 | 5 | return `${urlParts[3]}/${urlParts[4]}`; 6 | }; 7 | -------------------------------------------------------------------------------- /chrome-extension/src/features/octokit.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/core'; 2 | 3 | // Initialize octokit instance 4 | const initOctokit = (accessToken: null | void | string) => 5 | new Octokit({ auth: accessToken }); 6 | 7 | export { initOctokit }; 8 | -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | Jinja2==3.1.2 2 | google-auth==2.13.0 3 | dynaconf==3.1.11 4 | PyMySQL==1.0.2 5 | sqlmodel==0.0.8 6 | fastapi[all]==0.85.0 7 | gunicorn==20.1.0 8 | pydantic>=1.8.0,<2.0.0 9 | uvicorn>=0.15.0,<0.16.0 10 | psycopg2-binary==2.9.5 11 | rollbar==0.16.3 12 | pdfkit==1.0.0 -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/asyncForEach.ts: -------------------------------------------------------------------------------- 1 | export const asyncForEach = async ( 2 | array: any[], 3 | callback: (item: any, index: number, array: any[]) => Promise, 4 | ) => { 5 | for (let index = 0; index < array.length; index++) { 6 | await callback(array[index], index, array); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.6 2 | 3 | WORKDIR /app 4 | 5 | COPY . /app 6 | 7 | RUN pip install --trusted-host pypi.python.org -r requirements.txt 8 | 9 | RUN apt-get update && apt-get install -y wkhtmltopdf 10 | 11 | EXPOSE 8000 12 | 13 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/getOctokitRepoData.ts: -------------------------------------------------------------------------------- 1 | export const getOctokitRepoData = ( 2 | repoUrl: string, 3 | ): { owner: string; name: string } => { 4 | const urlParts = repoUrl.split('/'); 5 | const owner = urlParts[3] ?? ''; 6 | const name = urlParts[4] ?? ''; 7 | 8 | return { owner, name }; 9 | }; 10 | -------------------------------------------------------------------------------- /server/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | 3 | Generate new migration from model 4 | ``` 5 | alembic revision --autogenerate -m 6 | ``` 7 | 8 | Apply migration to DB 9 | ``` 10 | alembic upgrade head 11 | ``` 12 | 13 | Downgrade migration 14 | ``` 15 | alembic downgrade 16 | ``` -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/getLocation.ts: -------------------------------------------------------------------------------- 1 | const PHOTON_LOCATION_API_Q = 'https://photon.komoot.io/api/?limit=1&q='; 2 | 3 | export const getLocation = async (location: string) => { 4 | // using a geocoding API, get location data for a given string 5 | const r = await fetch(`${PHOTON_LOCATION_API_Q}${location}`); 6 | 7 | return r.json(); 8 | }; 9 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/initUrl.ts: -------------------------------------------------------------------------------- 1 | export const initUrl = () => 2 | // get current tab url if it is a github repo 3 | new Promise((resolve) => { 4 | chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs) => { 5 | const { url } = tabs[0]; 6 | resolve(url?.includes('https://github.com/') ? url : ''); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /chrome-extension/src/features/queries/index.ts: -------------------------------------------------------------------------------- 1 | export { repositoryQuery } from './repositoryQuery'; 2 | export { getForkersQuery } from './getForkersQuery'; 3 | export { getStargazersQuery } from './getStargazersQuery'; 4 | export { issuesQuery } from './issuesQuery'; 5 | export { pullRequestsQuery } from './pullRequestsQuery'; 6 | export { starHistoryQuery } from './starHistoryQuery'; 7 | -------------------------------------------------------------------------------- /chrome-extension/src/features/gql/queries/index.ts: -------------------------------------------------------------------------------- 1 | export { repositoryQuery } from './repositoryQuery'; 2 | export { getForkersQuery } from './getForkersQuery'; 3 | export { getStargazersQuery } from './getStargazersQuery'; 4 | export { issuesQuery } from './issuesQuery'; 5 | export { pullRequestsQuery } from './pullRequestsQuery'; 6 | export { starHistoryQuery } from './starHistoryQuery'; 7 | -------------------------------------------------------------------------------- /chrome-extension/graphql.config.yml: -------------------------------------------------------------------------------- 1 | schema: https://docs.github.com/public/schema.docs.graphql 2 | overwrite: true 3 | documents: 4 | [ 5 | './src/features/gql/mutations/**/*.(graphql|ts|js)', 6 | './src/features/gql/queries/**/*.(graphql|ts|js)', 7 | ] 8 | generates: 9 | ./src/features/gql/graphql.schema.ts: 10 | plugins: 11 | - typescript 12 | - typescript-operations 13 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/getOwnTabs.ts: -------------------------------------------------------------------------------- 1 | // check if download tab is open 2 | export const getOwnTabs = () => 3 | Promise.all( 4 | chrome.extension.getViews({ type: 'tab' }).map( 5 | (view) => 6 | new Promise((resolve) => { 7 | view.chrome.tabs.getCurrent((tab) => 8 | resolve(Object.assign(tab || {}, { url: view.location.href })), 9 | ); 10 | }), 11 | ), 12 | ); 13 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/initToken.ts: -------------------------------------------------------------------------------- 1 | export const initToken = () => 2 | // fetch github token from memory 3 | new Promise((resolve) => { 4 | chrome.storage.local.get( 5 | 'githubInspectorToken', 6 | async ({ githubInspectorToken }) => { 7 | if (githubInspectorToken) { 8 | resolve(githubInspectorToken); 9 | } 10 | 11 | resolve(); 12 | }, 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /chrome-extension/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "repoInspector", 4 | "description": "Inspect repositories", 5 | "version": "1.0.1", 6 | "action": { 7 | "default_popup": "popup.html" 8 | }, 9 | "permissions": ["storage", "unlimitedStorage", "activeTab"], 10 | "options_page": "options.html", 11 | "icons": { 12 | "16": "images/icon16.png", 13 | "48": "images/icon48.png", 14 | "128": "images/icon128.png" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for repoinspector on 2023-09-29T11:49:09+03:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "repoinspector" 7 | primary_region = "waw" 8 | 9 | [build] 10 | 11 | [http_service] 12 | internal_port = 8000 13 | force_https = true 14 | auto_stop_machines = true 15 | auto_start_machines = true 16 | min_machines_running = 0 17 | processes = ["app"] 18 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/getPullRequestStatistic.ts: -------------------------------------------------------------------------------- 1 | export const getPullRequestStatistic = (prs: PullRequest[]) => { 2 | const currentDate = new Date(); 3 | const twelveMonthsAgo = new Date().setFullYear(currentDate.getFullYear() - 1); 4 | let prsMergedLTM = 0; 5 | 6 | prs.forEach((pr) => { 7 | const closedAt = pr.node.closedAt 8 | ? new Date(pr.node.closedAt).getTime() 9 | : null; 10 | 11 | if (closedAt && closedAt >= twelveMonthsAgo) prsMergedLTM++; 12 | }); 13 | 14 | return prsMergedLTM; 15 | }; 16 | -------------------------------------------------------------------------------- /server/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | BASE_PATH = os.path.dirname(os.path.abspath(__file__)) 3 | from dynaconf import Dynaconf 4 | 5 | def load_settings(): 6 | return Dynaconf( 7 | envvar_prefix="API", 8 | preload=[os.path.join(BASE_PATH, "default.toml")], 9 | settings_files=[os.path.join(BASE_PATH, "settings.toml"), os.path.join(BASE_PATH, ".secrets.toml")], 10 | environments=["development", "production", "testing"], 11 | env_switcher="API_env", 12 | load_dotenv=False, 13 | ) 14 | 15 | settings = load_settings() -------------------------------------------------------------------------------- /chrome-extension/src/features/queries/starHistoryQuery.ts: -------------------------------------------------------------------------------- 1 | export const starHistoryQuery = ` 2 | query issuesList($owner: String!, $name: String!, $cursor: String = null){ 3 | repository(owner: $owner, name: $name){ 4 | stargazers(first: 100, after: $cursor, orderBy: {field: STARRED_AT, direction: DESC}) { 5 | totalCount 6 | edges { 7 | starredAt 8 | } 9 | pageInfo { 10 | endCursor 11 | hasNextPage 12 | } 13 | } 14 | } 15 | rateLimit { 16 | cost 17 | remaining 18 | resetAt 19 | } 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /chrome-extension/src/features/queries/repositoryQuery.ts: -------------------------------------------------------------------------------- 1 | export const repositoryQuery = ` 2 | query stargazersList($owner: String!, $name: String!){ 3 | repository(owner: $owner, name: $name){ 4 | stargazerCount 5 | stargazers(first: 0) { 6 | totalCount 7 | } 8 | forkCount 9 | forks(first: 0) { 10 | totalCount 11 | } 12 | issues(first: 0) { 13 | totalCount 14 | } 15 | pullRequests(first: 0) { 16 | totalCount 17 | } 18 | watchers(first: 0) { 19 | totalCount 20 | } 21 | } 22 | rateLimit { 23 | cost 24 | remaining 25 | resetAt 26 | } 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /server/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /chrome-extension/src/features/queries/issuesQuery.ts: -------------------------------------------------------------------------------- 1 | export const issuesQuery = ` 2 | query issuesList($owner: String!, $name: String!, $cursor: String = null){ 3 | repository(owner: $owner, name: $name){ 4 | issues(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) { 5 | totalCount 6 | edges { 7 | node { 8 | createdAt 9 | closed 10 | state 11 | closedAt 12 | } 13 | } 14 | pageInfo { 15 | endCursor 16 | hasNextPage 17 | } 18 | } 19 | } 20 | rateLimit { 21 | cost 22 | remaining 23 | resetAt 24 | } 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /chrome-extension/README.md: -------------------------------------------------------------------------------- 1 | # repo-inspector 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | 26 | 27 | # GEOCODING 28 | An important part of knowing your repo is knowing where people come from. nominatim.openstreetmap.org provides a free API which requires throttling to use, thus there is a tax of about 1.5 seconds for each API call -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: FastAPI", 9 | "type": "python", 10 | "request": "launch", 11 | "module": "uvicorn", 12 | "args": [ 13 | "main:app", 14 | "--port", 15 | "8005" 16 | ], 17 | "cwd": "${workspaceFolder}/server", 18 | "jinja": true 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /chrome-extension/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | repo-inspector 9 | 10 | 11 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /chrome-extension/src/features/queries/pullRequestsQuery.ts: -------------------------------------------------------------------------------- 1 | export const pullRequestsQuery = ` 2 | query pullRequestsList($owner: String!, $name: String!, $cursor: String = null){ 3 | repository(owner: $owner, name: $name){ 4 | pullRequests(first: 100, after: $cursor, states: MERGED, orderBy: {field: CREATED_AT, direction: DESC}) { 5 | totalCount 6 | edges { 7 | node { 8 | createdAt 9 | closed 10 | state 11 | closedAt 12 | } 13 | } 14 | pageInfo { 15 | endCursor 16 | hasNextPage 17 | } 18 | } 19 | } 20 | rateLimit { 21 | cost 22 | remaining 23 | resetAt 24 | } 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /chrome-extension/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | repo-inspector 9 | 10 | 11 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /chrome-extension/background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | repo-inspector 9 | 10 | 11 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /server/alembic/versions/ba474f47d3f2_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: ba474f47d3f2 4 | Revises: 5 | Create Date: 2023-07-25 14:24:30.218226 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ba474f47d3f2' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade() -> None: 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /chrome-extension/src/features/api.ts: -------------------------------------------------------------------------------- 1 | class Api { 2 | urlBase: string; 3 | 4 | constructor() { 5 | this.urlBase = 'https://repoinspector.fly.dev/'; 6 | } 7 | 8 | async post(url = '', data = {}) { 9 | const response = await fetch(`${this.urlBase}${url}`, { 10 | method: 'POST', 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | }, 14 | body: JSON.stringify(data), 15 | }); 16 | 17 | return response.json(); 18 | } 19 | 20 | async get(url = '') { 21 | const response = await fetch(`${this.urlBase}${url}`, { 22 | method: 'GET', 23 | headers: { 24 | 'Content-Type': 'application/json', 25 | }, 26 | }); 27 | 28 | return response.json(); 29 | } 30 | } 31 | 32 | export const api = new Api(); 33 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './asyncForEach'; 2 | export * from './createName'; 3 | export * from './getOwnTabs'; 4 | export * from './initToken'; 5 | export * from './initUrl'; 6 | export * from './octokitRepoUrl'; 7 | export * from './popupCenter'; 8 | export * from './timeout'; 9 | export { getOctokitRepoData } from './getOctokitRepoData'; 10 | export { getLocation } from './getLocation'; 11 | export { serializeUser } from './serializeUser'; 12 | export { groupStarsHistoryByMonth } from './groupStarsHistoryByMonth'; 13 | export { getIssuesStatistic } from './getIssuesStatistic'; 14 | export { getPullRequestStatistic } from './getPullRequestStatistic'; 15 | export { getYearMonth } from './getYearMonth'; 16 | export { loadFromHistory } from './loadFromHistory'; 17 | -------------------------------------------------------------------------------- /server/db.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | from sqlmodel import Session, SQLModel, create_engine 3 | from models import SQLModel 4 | from config import settings 5 | 6 | engine = create_engine( 7 | settings.DATABASE_URL, 8 | echo=False, 9 | # connect_args={'options': '-csearch_path={}'.format('repo_inspector')} 10 | ) 11 | 12 | def update_engine(new_engine): 13 | global_vars = globals() 14 | global_vars['engine'] = new_engine 15 | 16 | 17 | def create_db_and_tables(engine): 18 | SQLModel.metadata.create_all(engine) 19 | 20 | 21 | def get_session(): 22 | with Session(engine) as session: 23 | try: 24 | yield session 25 | finally: 26 | session.close() 27 | 28 | 29 | ActiveSession = Depends(get_session) 30 | 31 | create_db_and_tables(engine) -------------------------------------------------------------------------------- /chrome-extension/src/features/gql/queries/repositoryQuery.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag'; 2 | import { print } from 'graphql'; 3 | 4 | export const query = gql` 5 | query repositoryQuery($owner: String!, $name: String!) { 6 | repository(owner: $owner, name: $name) { 7 | stargazerCount 8 | stargazers(first: 0) { 9 | totalCount 10 | } 11 | forkCount 12 | forks(first: 0) { 13 | totalCount 14 | } 15 | issues(first: 0) { 16 | totalCount 17 | } 18 | pullRequests(first: 0) { 19 | totalCount 20 | } 21 | watchers(first: 0) { 22 | totalCount 23 | } 24 | } 25 | rateLimit { 26 | cost 27 | remaining 28 | resetAt 29 | } 30 | } 31 | `; 32 | 33 | export const repositoryQuery = print(query); 34 | -------------------------------------------------------------------------------- /chrome-extension/src/features/gql/queries/starHistoryQuery.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag'; 2 | import { print } from 'graphql'; 3 | 4 | export const query = gql` 5 | query starHistoryQuery( 6 | $owner: String! 7 | $name: String! 8 | $cursor: String = null 9 | ) { 10 | repository(owner: $owner, name: $name) { 11 | stargazers( 12 | first: 100 13 | after: $cursor 14 | orderBy: { field: STARRED_AT, direction: DESC } 15 | ) { 16 | totalCount 17 | edges { 18 | starredAt 19 | } 20 | pageInfo { 21 | endCursor 22 | hasNextPage 23 | } 24 | } 25 | } 26 | rateLimit { 27 | cost 28 | remaining 29 | resetAt 30 | } 31 | } 32 | `; 33 | 34 | export const starHistoryQuery = print(query); 35 | -------------------------------------------------------------------------------- /chrome-extension/src/assets/scss/transition.scss: -------------------------------------------------------------------------------- 1 | /* we will explain what these classes do next! */ 2 | .v-enter-active, 3 | .v-leave-active { 4 | transition: opacity 0.5s ease; 5 | } 6 | 7 | .v-enter-from, 8 | .v-leave-to { 9 | opacity: 0; 10 | } 11 | 12 | .bounce-enter-active { 13 | animation: bounce-in 0.5s; 14 | } 15 | .bounce-leave-active { 16 | animation: bounce-in 0.5s reverse; 17 | } 18 | @keyframes bounce-in { 19 | 0% { 20 | transform: scale(0); 21 | } 22 | 50% { 23 | transform: scale(1.25); 24 | } 25 | 100% { 26 | transform: scale(1); 27 | } 28 | } 29 | 30 | 31 | .slide-fade-enter-active { 32 | transition: all 0.3s ease-out; 33 | } 34 | 35 | .slide-fade-leave-active { 36 | transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1); 37 | } 38 | 39 | .slide-fade-enter-from, 40 | .slide-fade-leave-to { 41 | transform: translateX(20px); 42 | opacity: 0; 43 | } 44 | -------------------------------------------------------------------------------- /chrome-extension/src/features/gql/queries/issuesQuery.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag'; 2 | import { print } from 'graphql'; 3 | 4 | export const query = gql` 5 | query issuesQuery($owner: String!, $name: String!, $cursor: String = null) { 6 | repository(owner: $owner, name: $name) { 7 | issues( 8 | first: 100 9 | after: $cursor 10 | orderBy: { field: CREATED_AT, direction: DESC } 11 | ) { 12 | totalCount 13 | edges { 14 | node { 15 | createdAt 16 | closed 17 | state 18 | closedAt 19 | } 20 | } 21 | pageInfo { 22 | endCursor 23 | hasNextPage 24 | } 25 | } 26 | } 27 | rateLimit { 28 | cost 29 | remaining 30 | resetAt 31 | } 32 | } 33 | `; 34 | 35 | export const issuesQuery = print(query); 36 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/getKeysForStatisticPeriod.ts: -------------------------------------------------------------------------------- 1 | export const getKeysForStatisticPeriod = (statistic: { 2 | [key: string]: unknown; 3 | }) => { 4 | const currentDate = new Date(); 5 | const currentYear = currentDate.getFullYear(); 6 | const currentMonth = currentDate.getMonth() + 1; 7 | 8 | const sortedDates = Object.keys(statistic).sort((a, b) => a.localeCompare(b)); 9 | const startDate = sortedDates[0]; 10 | 11 | const filledMonths = []; 12 | let [year, month] = startDate.split('.').map(Number); 13 | 14 | while ( 15 | !(year > currentYear || (year === currentYear && month > currentMonth)) 16 | ) { 17 | const dateKey = `${year}.${month.toString().padStart(2, '0')}`; 18 | filledMonths.push(dateKey); 19 | 20 | month++; 21 | 22 | if (month > 12) { 23 | year++; 24 | month = 1; 25 | } 26 | } 27 | 28 | return filledMonths; 29 | }; 30 | -------------------------------------------------------------------------------- /chrome-extension/src/features/gql/queries/pullRequestsQuery.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag'; 2 | import { print } from 'graphql'; 3 | 4 | export const query = gql` 5 | query pullRequestsQuery( 6 | $owner: String! 7 | $name: String! 8 | $cursor: String = null 9 | ) { 10 | repository(owner: $owner, name: $name) { 11 | pullRequests( 12 | first: 100 13 | after: $cursor 14 | states: MERGED 15 | orderBy: { field: CREATED_AT, direction: DESC } 16 | ) { 17 | totalCount 18 | edges { 19 | node { 20 | createdAt 21 | closed 22 | state 23 | closedAt 24 | } 25 | } 26 | pageInfo { 27 | endCursor 28 | hasNextPage 29 | } 30 | } 31 | } 32 | rateLimit { 33 | cost 34 | remaining 35 | resetAt 36 | } 37 | } 38 | `; 39 | 40 | export const pullRequestsQuery = print(query); 41 | -------------------------------------------------------------------------------- /chrome-extension/src/features/queries/getStargazersQuery.ts: -------------------------------------------------------------------------------- 1 | export const getStargazersQuery = (limit: number): string => ` 2 | query stargazersList($owner: String!, $name: String!, $cursor: String = null){ 3 | repository(owner: $owner, name: $name){ 4 | stargazers(first: ${limit}, after: $cursor, orderBy: {field: STARRED_AT, direction: DESC}) { 5 | edges { 6 | node { 7 | login 8 | createdAt 9 | updatedAt 10 | name 11 | location 12 | __typename 13 | isSiteAdmin 14 | company 15 | bio 16 | email 17 | isHireable 18 | twitterUsername 19 | websiteUrl 20 | followers(first: 0) { 21 | totalCount 22 | } 23 | following(first:0){ 24 | totalCount 25 | } 26 | } 27 | } 28 | pageInfo { 29 | endCursor 30 | hasNextPage 31 | } 32 | } 33 | } 34 | rateLimit { 35 | cost 36 | remaining 37 | resetAt 38 | } 39 | } 40 | `; 41 | -------------------------------------------------------------------------------- /chrome-extension/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Makes ESlint extension format code on save 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": true 7 | }, 8 | "eslint.validate": [ 9 | "javascript", 10 | "typescript", 11 | "vue" 12 | ], 13 | // Eslint-related rules 14 | "eslint.enable": true, 15 | "eslint.options": { 16 | "overrideConfigFile": "/chrome-extension/.eslintrc" 17 | }, 18 | "[jsonc]": { 19 | "editor.defaultFormatter": "vscode.json-language-features" 20 | }, 21 | "[typescript]": { 22 | "editor.defaultFormatter": "esbenp.prettier-vscode", 23 | "editor.formatOnSave": true, 24 | "editor.codeActionsOnSave": { 25 | "source.fixAll.eslint": true 26 | } 27 | }, 28 | // Add new line in the end of file 29 | "files.insertFinalNewline": true, 30 | // Trim trailing whitespace in the end of file 31 | "files.trimFinalNewlines": true 32 | } 33 | -------------------------------------------------------------------------------- /server/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.templating import Jinja2Templates 3 | from starlette.middleware import Middleware 4 | from starlette.middleware.cors import CORSMiddleware 5 | from fastapi.staticfiles import StaticFiles 6 | 7 | import rollbar 8 | from rollbar.contrib.fastapi import ReporterMiddleware as RollbarMiddleware 9 | import os 10 | 11 | def init_rollbar(app): 12 | # Initialize Rollbar SDK with your server-side access token 13 | rollbar_api = os.environ.get('ROLLBAR_API') 14 | if rollbar_api: 15 | rollbar.init( 16 | access_token=rollbar_api, 17 | environment=os.environ.get('ROLLBAR_ENV', 'local') 18 | ) 19 | 20 | app.add_middleware(RollbarMiddleware) 21 | 22 | 23 | # Integrate Rollbar with FastAPI application before adding routes to the app 24 | app = FastAPI(middleware=[ 25 | Middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) 26 | ]) 27 | init_rollbar(app) 28 | 29 | app.mount("/static", StaticFiles(directory="static"), name="static") 30 | templates = Jinja2Templates(directory="templates") -------------------------------------------------------------------------------- /chrome-extension/src/features/store/history.ts: -------------------------------------------------------------------------------- 1 | import { HISTORY_MODEL } from './models'; 2 | 3 | export class HistoryStore { 4 | constructor() { 5 | chrome.storage.local.get(async ({ URL_HISTORY }) => { 6 | if (!URL_HISTORY) { 7 | chrome.storage.local.set({ URL_HISTORY: HISTORY_MODEL }); 8 | } 9 | }); 10 | } 11 | 12 | async reset() { 13 | await chrome.storage.local.set({ URL_HISTORY: HISTORY_MODEL }); 14 | } 15 | 16 | async get(): Promise { 17 | const { URL_HISTORY = [] } = (await chrome.storage.local.get()) as { 18 | URL_HISTORY: HistoryType; 19 | }; 20 | 21 | return URL_HISTORY; 22 | } 23 | 24 | async set(value: Downloader) { 25 | const URL_HISTORY = await this.get(); 26 | 27 | await chrome.storage.local.set({ URL_HISTORY: [value, ...URL_HISTORY] }); 28 | } 29 | 30 | async remove(i: number) { 31 | // remove data for url in URL_HISTORY 32 | const URL_HISTORY = await this.get(); 33 | 34 | URL_HISTORY.splice(i, 1); 35 | 36 | await chrome.storage.local.set({ URL_HISTORY }); 37 | } 38 | } 39 | 40 | export const historyStore = new HistoryStore(); 41 | -------------------------------------------------------------------------------- /chrome-extension/src/features/queries/getForkersQuery.ts: -------------------------------------------------------------------------------- 1 | export const getForkersQuery = (limit: number): string => ` 2 | query forkersList($owner: String!, $name: String!, $cursor: String = null){ 3 | repository(owner: $owner, name: $name){ 4 | forks(first: ${limit}, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) { 5 | edges { 6 | node { 7 | owner { 8 | ... on User { 9 | login 10 | createdAt 11 | updatedAt 12 | name 13 | location 14 | __typename 15 | isSiteAdmin 16 | company 17 | bio 18 | email 19 | isHireable 20 | twitterUsername 21 | websiteUrl 22 | followers(first: 0) { 23 | totalCount 24 | } 25 | following(first:0){ 26 | totalCount 27 | } 28 | } 29 | } 30 | } 31 | } 32 | pageInfo { 33 | endCursor 34 | hasNextPage 35 | } 36 | } 37 | } 38 | rateLimit { 39 | cost 40 | remaining 41 | resetAt 42 | } 43 | } 44 | `; 45 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/loadFromHistory.ts: -------------------------------------------------------------------------------- 1 | import { downloaderStore } from '../store/downloader'; 2 | import { inspectDataStore } from '../store/inspectData'; 3 | import { STAGE } from '../store/models'; 4 | 5 | export const loadFromHistory = async (downloader: Downloader) => { 6 | const { 7 | forks_users_data, 8 | stargazers_users_data, 9 | issues_statistic, 10 | stars_history, 11 | prsMergedLTM, 12 | lastMonthStars, 13 | } = downloader; 14 | 15 | const fork_users = forks_users_data?.length ? [...forks_users_data] : []; 16 | const stargaze_users = stargazers_users_data?.length 17 | ? [...stargazers_users_data] 18 | : []; 19 | 20 | const inspectData: InspectData = { 21 | fork_users, 22 | stargaze_users, 23 | issues: issues_statistic ?? {}, 24 | stars_history: stars_history ?? {}, 25 | pull_requests_merged_LTM: prsMergedLTM ?? 0, 26 | lastMonthStars: lastMonthStars ?? 0, 27 | }; 28 | 29 | inspectDataStore.load(inspectData); 30 | 31 | await downloaderStore.set({ 32 | ...downloader, 33 | active: true, 34 | stage: STAGE.UNPAUSED, 35 | forks_users_data: [...fork_users], 36 | stargazers_users_data: [...stargaze_users], 37 | }); 38 | 39 | return { success: true }; 40 | }; 41 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/popupCenter.ts: -------------------------------------------------------------------------------- 1 | export const popupCenter = ({ 2 | url, 3 | title, 4 | w, 5 | h, 6 | }: { 7 | url: string; 8 | title: string; 9 | w: number; 10 | h: number; 11 | }) => { 12 | // Fixes dual-screen position Most browsers Firefox 13 | const dualScreenLeft = 14 | window.screenLeft !== undefined ? window.screenLeft : window.screenX; 15 | const dualScreenTop = 16 | window.screenTop !== undefined ? window.screenTop : window.screenY; 17 | 18 | const width = 19 | window.innerWidth || 20 | document.documentElement.clientWidth || 21 | window.screen.width; 22 | const height = 23 | window.innerHeight || 24 | document.documentElement.clientHeight || 25 | window.screen.height; 26 | 27 | const systemZoom = width / window.screen.availWidth; 28 | const left = (width - w) / 2 / systemZoom + dualScreenLeft; 29 | const top = (height - h) / 2 / systemZoom + dualScreenTop; 30 | 31 | const newWindow = window.open( 32 | url, 33 | title, 34 | ` 35 | scrollbars=yes, 36 | width=${w / systemZoom}, 37 | height=${h / systemZoom}, 38 | top=${top}, 39 | left=${left} 40 | `, 41 | ); 42 | 43 | if (newWindow !== null) { 44 | newWindow.focus(); 45 | } 46 | 47 | return newWindow; 48 | }; 49 | -------------------------------------------------------------------------------- /chrome-extension/src/features/gql/queries/getStargazersQuery.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag'; 2 | import { print } from 'graphql'; 3 | 4 | export const query = gql` 5 | query getStargazersQuery( 6 | $owner: String! 7 | $name: String! 8 | $cursor: String = null 9 | $limit: Int 10 | ) { 11 | repository(owner: $owner, name: $name) { 12 | stargazers( 13 | first: $limit 14 | after: $cursor 15 | orderBy: { field: STARRED_AT, direction: DESC } 16 | ) { 17 | edges { 18 | node { 19 | login 20 | createdAt 21 | updatedAt 22 | name 23 | location 24 | __typename 25 | isSiteAdmin 26 | company 27 | bio 28 | email 29 | isHireable 30 | twitterUsername 31 | websiteUrl 32 | followers(first: 0) { 33 | totalCount 34 | } 35 | following(first: 0) { 36 | totalCount 37 | } 38 | } 39 | } 40 | pageInfo { 41 | endCursor 42 | hasNextPage 43 | } 44 | } 45 | } 46 | rateLimit { 47 | cost 48 | remaining 49 | resetAt 50 | } 51 | } 52 | `; 53 | 54 | export const getStargazersQuery = print(query); 55 | -------------------------------------------------------------------------------- /chrome-extension/src/features/store/models.ts: -------------------------------------------------------------------------------- 1 | export const HISTORY_MODEL = []; 2 | 3 | export const NOTIFICATION_MODEL = []; 4 | 5 | export const DOWNLOADER_MODEL: Downloader = { 6 | id: null, 7 | active: false, 8 | stage: 0, 9 | date: null, 10 | url: '', 11 | octokitUrl: '', 12 | name: '', 13 | progress: { 14 | current: 0, 15 | max: 0, 16 | }, 17 | stargazers_count: 0, 18 | stargazers_users: 0, 19 | forks_count: 0, 20 | forks_users: 0, 21 | issues_count: 0, 22 | pull_requests_count: 0, 23 | watchers_count: 0, 24 | contributors_count: 0, 25 | settings: { 26 | stars: true, 27 | forks: false, 28 | sample: false, 29 | samplePercent: 0, 30 | location: false, 31 | }, 32 | totalRatingWeights: { 33 | starsWeight: 0.225, 34 | contributorsWeight: 0.2, 35 | starsGrowthWeight: 0.05, 36 | starsActivityWeight: 0.125, 37 | forksStarsWeight: 0.15, 38 | issuesOpenedLTMWeight: 0.05, 39 | issuesClosedLTMWeight: 0.05, 40 | PRMergedLTMWeight: 0.15, 41 | }, 42 | }; 43 | 44 | export const INSPECT_DATA_DB: InspectData = { 45 | fork_users: [], 46 | stargaze_users: [], 47 | issues: {}, 48 | pull_requests_merged_LTM: 0, 49 | stars_history: {}, 50 | lastMonthStars: 0, 51 | }; 52 | 53 | export const STAGE = { 54 | NOT_STARTED: 0, 55 | INITIATED: 1, 56 | UNPAUSED: 2, 57 | GETTING_ADDITIONAL_STATISTIC: 3, 58 | GETTING_USERS: 4, 59 | DONE: 5, 60 | ERROR: 6, 61 | PAUSE: 7, 62 | }; 63 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/groupStarsHistoryByMonth.ts: -------------------------------------------------------------------------------- 1 | import { getYearMonth } from './getYearMonth'; 2 | import { getKeysForStatisticPeriod } from './getKeysForStatisticPeriod'; 3 | 4 | const fillMissingMonths = (starHistory: StarHistoryByMonth) => { 5 | const filledStarHistory = { ...starHistory }; 6 | const allMonths = getKeysForStatisticPeriod(starHistory); 7 | let totalCount = 0; 8 | 9 | allMonths.forEach((key) => { 10 | if (key in filledStarHistory) { 11 | totalCount += filledStarHistory[key].count; 12 | filledStarHistory[key].count = totalCount; 13 | } else { 14 | filledStarHistory[key] = { count: totalCount }; 15 | } 16 | }); 17 | 18 | return filledStarHistory; 19 | }; 20 | 21 | export const groupStarsHistoryByMonth = (starHistory: StarHistory[]) => { 22 | const result: StarHistoryByMonth = {}; 23 | const now = new Date(); 24 | const currentMonthKey = getYearMonth(now); 25 | 26 | starHistory.forEach((item) => { 27 | const date = new Date(item.starredAt); 28 | const yearMonth = getYearMonth(date); 29 | 30 | if (!result[yearMonth]) { 31 | result[yearMonth] = { 32 | count: 0, 33 | }; 34 | } 35 | 36 | result[yearMonth].count++; 37 | }); 38 | 39 | const lastMonthStars = result[currentMonthKey] 40 | ? result[currentMonthKey].count 41 | : 0; 42 | 43 | const stars_history = fillMissingMonths(result); 44 | 45 | return { stars_history, lastMonthStars }; 46 | }; 47 | -------------------------------------------------------------------------------- /chrome-extension/src/features/store/inspectData.ts: -------------------------------------------------------------------------------- 1 | import { INSPECT_DATA_DB } from './models'; 2 | 3 | class InspectDataStore { 4 | // Global class which holds the collected data from repositories. Saving this data to the chrome storage results in errors and many 5 | // writes, so all data will be stored in a global variable and sent to a server for further processing when it is all collected. 6 | // This class is primarily used by the background.js file. When the browser is shut down, this data is cleared. This means that if a repo 7 | // was not completely parsed, it will start from scratch. 8 | inspectDataDb: InspectData; 9 | 10 | constructor() { 11 | // variable holding all repo data currently being collected 12 | this.inspectDataDb = { ...INSPECT_DATA_DB }; 13 | } 14 | 15 | refresh() { 16 | // initialize this.inspectDataDb to an empty dataset 17 | this.inspectDataDb = { ...INSPECT_DATA_DB }; 18 | } 19 | 20 | set( 21 | type: keyof InspectData, 22 | value: DBUser[] | IssuesStatistic | number | StarHistoryByMonth | Date, 23 | ) { 24 | // set data to global variable by key value 25 | this.inspectDataDb[type] = value as DBUser[] & 26 | IssuesStatistic & 27 | number & 28 | StarHistoryByMonth & 29 | Date; 30 | } 31 | 32 | load(data: InspectData) { 33 | this.inspectDataDb = data; 34 | } 35 | } 36 | 37 | // export global instance of InspectDataStore class 38 | export const inspectDataStore = new InspectDataStore(); 39 | -------------------------------------------------------------------------------- /chrome-extension/src/features/gql/queries/getForkersQuery.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag'; 2 | import { print } from 'graphql'; 3 | 4 | export const query = gql` 5 | query getForkersQuery( 6 | $owner: String! 7 | $name: String! 8 | $cursor: String = null 9 | $limit: Int 10 | ) { 11 | repository(owner: $owner, name: $name) { 12 | forks( 13 | first: $limit 14 | after: $cursor 15 | orderBy: { field: CREATED_AT, direction: DESC } 16 | ) { 17 | edges { 18 | node { 19 | owner { 20 | ... on User { 21 | login 22 | createdAt 23 | updatedAt 24 | name 25 | location 26 | __typename 27 | isSiteAdmin 28 | company 29 | bio 30 | email 31 | isHireable 32 | twitterUsername 33 | websiteUrl 34 | followers(first: 0) { 35 | totalCount 36 | } 37 | following(first: 0) { 38 | totalCount 39 | } 40 | } 41 | } 42 | } 43 | } 44 | pageInfo { 45 | endCursor 46 | hasNextPage 47 | } 48 | } 49 | } 50 | rateLimit { 51 | cost 52 | remaining 53 | resetAt 54 | } 55 | } 56 | `; 57 | 58 | export const getForkersQuery = print(query); 59 | -------------------------------------------------------------------------------- /server/utils/sender.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | from email import encoders 3 | from email.mime.base import MIMEBase 4 | from email.mime.multipart import MIMEMultipart 5 | from email.mime.text import MIMEText 6 | from config import settings 7 | from email.utils import formataddr 8 | 9 | from models import EmailUser 10 | 11 | class EmailNotifier: 12 | _message_template = 'Subject: {subject}\n\n{message}' 13 | 14 | def __init__(self): 15 | self.sender_email = settings.smtp_username 16 | self.sender_pwd = settings.smtp_password 17 | self.smtp_server = settings.smtp_server 18 | self.port = settings.smtp_port 19 | 20 | def send_message( 21 | self, 22 | subject: str, 23 | message: str, 24 | recipient: EmailUser, 25 | file_path: str = None, 26 | ): 27 | 28 | with(smtplib.SMTP_SSL(self.smtp_server, self.port)) as server: 29 | server.login(self.sender_email, self.sender_pwd) 30 | 31 | msg = MIMEMultipart() 32 | msg['From'] = formataddr(('Repo Inspector', self.sender_email)) 33 | msg['To'] = formataddr((recipient.name, recipient.email)) 34 | msg['Subject'] = subject 35 | msg.attach(MIMEText(message, 'html')) 36 | 37 | part = MIMEBase('application', "octet-stream") 38 | 39 | if file_path: 40 | part.set_payload(open(file_path, "rb").read()) 41 | encoders.encode_base64(part) 42 | part.add_header('Content-Disposition', 'attachment', filename=file_path) 43 | msg.attach(part) 44 | 45 | server.sendmail(self.sender_email, [recipient.email], msg.as_string()) 46 | 47 | 48 | email_sender = EmailNotifier() -------------------------------------------------------------------------------- /chrome-extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "Node", 5 | "resolveJsonModule": true, 6 | "useDefineForClassFields": true, 7 | // Required in Vue projects 8 | "jsx": "preserve", 9 | // `"noImplicitThis": true` is part of `strict` 10 | // Added again here in case some users decide to disable `strict`. 11 | // This enables stricter inference for data properties on `this`. 12 | "noImplicitThis": true, 13 | "strict": true, 14 | // Required in Vite 15 | "isolatedModules": true, 16 | "verbatimModuleSyntax": true, 17 | // A few notes: 18 | // - Vue 3 supports ES2016+ 19 | // - For Vite, the actual compilation target is determined by the 20 | // `build.target` option in the Vite config. 21 | // So don't change the `target` field here. It has to be 22 | // at least `ES2020` for dynamic `import()`s and `import.meta` to work correctly. 23 | // - If you are not using Vite, feel free to override the `target` field. 24 | "target": "ESNext", 25 | // Recommended 26 | "esModuleInterop": true, 27 | "forceConsistentCasingInFileNames": true, 28 | // See 29 | "skipLibCheck": true, 30 | "types": [ 31 | "chrome" 32 | ], 33 | "paths": { 34 | "@/*": [ 35 | "./src/*" 36 | ], 37 | }, 38 | }, 39 | "include": [ 40 | "**/*.ts", 41 | "**/*.tsx", 42 | "**/*.vue", 43 | "src/global.d.ts" 44 | ], 45 | "exclude": [ 46 | "node_modules", 47 | "server" 48 | ], 49 | } 50 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/calculateTotalRating.ts: -------------------------------------------------------------------------------- 1 | const normalizer = (value: number, min: number, max: number) => 2 | ((value - min) / (max - min)) * 100; 3 | 4 | export const calculateTotalRating = ( 5 | data: { [key: string]: number }, 6 | totalRatingWeights: TotalRatingWeights, 7 | ) => { 8 | const values = Object.values(data); 9 | const max = Math.max(...values); 10 | const min = Math.min(...values); 11 | const { 12 | starsWeight, 13 | contributorsWeight, 14 | starsGrowthWeight, 15 | starsActivityWeight, 16 | forksStarsWeight, 17 | issuesOpenedLTMWeight, 18 | issuesClosedLTMWeight, 19 | PRMergedLTMWeight, 20 | } = totalRatingWeights; 21 | 22 | const normalizeValue = (value: number) => normalizer(value, min, max); 23 | 24 | const starsRating = normalizeValue(data.stars) * starsWeight; 25 | const contributorsRating = 26 | normalizeValue(data.contributors) * contributorsWeight; 27 | const starsGrowthRating = 28 | normalizeValue(data.starsGrowth) * starsGrowthWeight; 29 | const starsActivityRating = 30 | normalizeValue(data.starsActivity) * starsActivityWeight; 31 | const forksStarsRating = normalizeValue(data.forksStars) * forksStarsWeight; 32 | const issuesOpenedLTMRating = 33 | normalizeValue(data.issuesOpenedLTM) * issuesOpenedLTMWeight; 34 | const issuesClosedLTMRating = 35 | normalizeValue(data.issuesClosedLTM) * issuesClosedLTMWeight; 36 | const PRMergedLTMRating = 37 | normalizeValue(data.pmMergedLTM) * PRMergedLTMWeight; 38 | 39 | const total_rating = 40 | starsRating + 41 | contributorsRating + 42 | starsGrowthRating + 43 | starsActivityRating + 44 | forksStarsRating + 45 | issuesOpenedLTMRating + 46 | issuesClosedLTMRating + 47 | PRMergedLTMRating; 48 | 49 | return Math.round(total_rating); 50 | }; 51 | -------------------------------------------------------------------------------- /chrome-extension/src/features/store/downloader.ts: -------------------------------------------------------------------------------- 1 | import { DOWNLOADER_MODEL } from './models'; 2 | 3 | class DownloaderStore { 4 | constructor() { 5 | chrome.storage.local.get(async ({ DOWNLOADER }) => { 6 | if (!DOWNLOADER ?? !DOWNLOADER.totalRatingWeights) { 7 | chrome.storage.local.set({ DOWNLOADER: DOWNLOADER_MODEL }); 8 | } 9 | }); 10 | } 11 | 12 | async reset() { 13 | await chrome.storage.local.set({ DOWNLOADER: DOWNLOADER_MODEL }); 14 | } 15 | 16 | async set(DOWNLOADER: Downloader) { 17 | // set data for url in DOWNLOADER 18 | await chrome.storage.local.set({ DOWNLOADER }); 19 | } 20 | 21 | async get(): Promise { 22 | const { DOWNLOADER } = (await chrome.storage.local.get()) as { 23 | DOWNLOADER: Downloader; 24 | }; 25 | 26 | return DOWNLOADER; 27 | } 28 | 29 | async setStage(stage: number) { 30 | const { DOWNLOADER } = (await chrome.storage.local.get()) as { 31 | DOWNLOADER: Downloader; 32 | }; 33 | 34 | const downloader = { ...DOWNLOADER, stage }; 35 | 36 | await chrome.storage.local.set({ DOWNLOADER: downloader }); 37 | } 38 | 39 | async setWeightsSettings(totalRatingWeights: TotalRatingWeights) { 40 | const { DOWNLOADER } = (await chrome.storage.local.get()) as { 41 | DOWNLOADER: Downloader; 42 | }; 43 | 44 | const downloader = { ...DOWNLOADER, totalRatingWeights }; 45 | 46 | await chrome.storage.local.set({ DOWNLOADER: downloader }); 47 | } 48 | 49 | async increaseProgress(value = 1) { 50 | const { DOWNLOADER } = (await chrome.storage.local.get()) as { 51 | DOWNLOADER: Downloader; 52 | }; 53 | 54 | const downloader = { 55 | ...DOWNLOADER, 56 | progress: { 57 | ...DOWNLOADER.progress, 58 | current: DOWNLOADER.progress.current + value, 59 | }, 60 | }; 61 | 62 | await chrome.storage.local.set({ DOWNLOADER: downloader }); 63 | } 64 | } 65 | 66 | export const downloaderStore = new DownloaderStore(); 67 | -------------------------------------------------------------------------------- /server/alembic/versions/9aa6d5120b0b_columns_for_additional_statistic_added.py: -------------------------------------------------------------------------------- 1 | """Columns for additional statistic added 2 | 3 | Revision ID: 9aa6d5120b0b 4 | Revises: ba474f47d3f2 5 | Create Date: 2023-07-25 14:25:52.894086 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '9aa6d5120b0b' 14 | down_revision = 'ba474f47d3f2' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('repository', sa.Column('contributors_count', sa.Integer(), nullable=True)) 22 | op.add_column('repository', sa.Column('issues_count', sa.Integer(), nullable=True)) 23 | op.add_column('repository', sa.Column('pull_requests_count', sa.Integer(), nullable=True)) 24 | op.add_column('repository', sa.Column('pull_requests_merged_ltm', sa.Integer(), nullable=True)) 25 | op.add_column('repository', sa.Column('watchers_count', sa.Integer(), nullable=True)) 26 | op.add_column('repository', sa.Column('health', sa.Integer(), nullable=True)) 27 | op.add_column('repository', sa.Column('issues_opened_ltm', sa.Integer(), nullable=True)) 28 | op.add_column('repository', sa.Column('issues_history', sa.String, nullable=True)) 29 | op.add_column('repository', sa.Column('stars_history', sa.String, nullable=True)) 30 | op.add_column('repository', sa.Column('last_month_stars', sa.Integer(), nullable=True)) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade() -> None: 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_column('repository', 'last_month_stars') 37 | op.drop_column('repository', 'stars_history') 38 | op.drop_column('repository', 'issues_history') 39 | op.drop_column('repository', 'issues_opened_ltm') 40 | op.drop_column('repository', 'health') 41 | op.drop_column('repository', 'watchers_count') 42 | op.drop_column('repository', 'pull_requests_merged_ltm') 43 | op.drop_column('repository', 'pull_requests_count') 44 | op.drop_column('repository', 'issues_count') 45 | op.drop_column('repository', 'contributors_count') 46 | # ### end Alembic commands ### 47 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/getIssuesStatistic.ts: -------------------------------------------------------------------------------- 1 | import { getYearMonth } from './getYearMonth'; 2 | import { getKeysForStatisticPeriod } from './getKeysForStatisticPeriod'; 3 | 4 | const fillMissingMonths = (chartData: ChartData) => { 5 | const filledChartData = { ...chartData }; 6 | const allMonths = getKeysForStatisticPeriod(chartData); 7 | 8 | allMonths.forEach((key) => { 9 | if (!(key in filledChartData)) { 10 | filledChartData[key] = { 11 | opened: 0, 12 | closed: 0, 13 | }; 14 | } 15 | }); 16 | 17 | return filledChartData; 18 | }; 19 | 20 | export const getIssuesStatistic = (issues: Issue[]) => { 21 | const currentDate = new Date(); 22 | const twelveMonthsAgo = new Date().setFullYear(currentDate.getFullYear() - 1); 23 | 24 | const chartData: ChartData = {}; 25 | let openedCount = 0; 26 | let closedCount = 0; 27 | 28 | issues.forEach((item) => { 29 | const createdAt = new Date(item.node.createdAt); 30 | const closedAt = item.node.closedAt ? new Date(item.node.closedAt) : null; 31 | const createdYearMonth = getYearMonth(createdAt); 32 | const closedYearMonth = closedAt ? getYearMonth(closedAt) : null; 33 | 34 | if (!chartData[createdYearMonth]) { 35 | chartData[createdYearMonth] = { 36 | opened: 0, 37 | closed: 0, 38 | }; 39 | } 40 | 41 | chartData[createdYearMonth].opened++; 42 | 43 | if (closedYearMonth && !chartData[closedYearMonth]) { 44 | chartData[closedYearMonth] = { 45 | opened: 0, 46 | closed: 0, 47 | }; 48 | } 49 | 50 | if (item.node.state === 'CLOSED' && closedYearMonth) { 51 | chartData[closedYearMonth].closed++; 52 | } 53 | 54 | if (createdAt.getTime() >= twelveMonthsAgo) { 55 | openedCount++; 56 | 57 | if ( 58 | item.node.state === 'CLOSED' && 59 | closedAt && 60 | closedAt.getTime() >= twelveMonthsAgo 61 | ) { 62 | closedCount++; 63 | } 64 | } 65 | }); 66 | 67 | const health = Number(((closedCount / openedCount) * 100)?.toFixed(2)) || 0; 68 | 69 | return { 70 | chartData: fillMissingMonths(chartData), 71 | health, 72 | openedLTM: openedCount, 73 | closedLTM: closedCount, 74 | }; 75 | }; 76 | -------------------------------------------------------------------------------- /chrome-extension/src/entry/background.ts: -------------------------------------------------------------------------------- 1 | import { downloaderStore } from '@/features/store/downloader'; 2 | 3 | const checkDownloadPage = async () => { 4 | chrome.tabs 5 | .query({ 6 | url: 'chrome-extension://gpbbcpjccbhdjnjkpbmkbdhhlocpfbne/options.html', 7 | }) 8 | .then(async (d) => { 9 | if (!d.length) { 10 | const downloader = await downloaderStore.get(); 11 | 12 | if (downloader.active) { 13 | let { NOTIFICATION_STATE } = await chrome.storage.local.get([ 14 | 'NOTIFICATION_STATE', 15 | ]); 16 | 17 | if (NOTIFICATION_STATE) { 18 | NOTIFICATION_STATE = false; 19 | chrome.storage.local.set({ NOTIFICATION_STATE }); 20 | const notificationId = new Date().getTime(); 21 | chrome.notifications.create(`notification_${notificationId}`, { 22 | type: 'basic', 23 | iconUrl: 'images/icon48.png', 24 | title: 'Download has paused', 25 | message: 'Click the extension to continue downloading', 26 | priority: 2, 27 | }); 28 | } 29 | } 30 | } 31 | 32 | const { FINISHED_REPO } = await chrome.storage.local.get([ 33 | 'FINISHED_REPO', 34 | ]); 35 | 36 | if (FINISHED_REPO) { 37 | await chrome.storage.local.remove(['FINISHED_REPO']); 38 | const notificationId = new Date().getTime(); 39 | chrome.notifications.create(`notification_${notificationId}`, { 40 | type: 'basic', 41 | iconUrl: 'images/icon48.png', 42 | title: 'Inspection complete', 43 | message: 'Nice! Your repo data has been sent to your email.', 44 | priority: 2, 45 | }); 46 | } 47 | }); 48 | }; 49 | 50 | chrome.runtime.onStartup.addListener(() => checkDownloadPage()); 51 | 52 | const initAlarm = () => { 53 | chrome.alarms.get('downloadPage', (a) => { 54 | if (!a) chrome.alarms.create('downloadPage', { periodInMinutes: 1.0 }); 55 | }); 56 | }; 57 | 58 | chrome.runtime.onStartup.addListener(() => { 59 | initAlarm(); 60 | }); 61 | 62 | chrome.runtime.onInstalled.addListener(() => { 63 | initAlarm(); 64 | }); 65 | 66 | chrome.alarms.onAlarm.addListener((alarm) => { 67 | if (alarm.name === 'downloadPage') { 68 | checkDownloadPage(); 69 | } 70 | }); 71 | -------------------------------------------------------------------------------- /chrome-extension/src/features/authentication.ts: -------------------------------------------------------------------------------- 1 | import { popupCenter } from './utils'; 2 | import { api } from './api'; 3 | 4 | export class Authentication { 5 | // Authentication is done via google oauth2 flow. As at the time of developing this extension google did not support login directly 6 | // from a chrome extension, we are logging in from a popup. In order to load the data properly into the extension, the extension 7 | // first creates a email_user at login/email_user/ and then allows for opening a the popup with that users uuid as a url param. 8 | // This allows us to open a url for logging in with google which is associated to the same user who we just created. On the next time 9 | // the chrome extension will be opened, the user will be refreshed with the new data from google. 10 | 11 | currentUser: CurrentUser; 12 | 13 | constructor() { 14 | this.currentUser = {}; 15 | 16 | chrome.storage.local.get(async ({ CURRENT_USER }) => { 17 | let currentUser = CURRENT_USER; 18 | 19 | if (!CURRENT_USER) { 20 | // create user for this extension instance to send emails to, 21 | // at this point there is no data from the user aside from the uuid 22 | const data = await api.post('login/email_user/'); 23 | currentUser = data; 24 | await chrome.storage.local.set({ CURRENT_USER: currentUser }); 25 | } else if (CURRENT_USER?.uuid && !CURRENT_USER.email) { 26 | // if we have created a user, make sure the login from google has worked and the email is updated 27 | const data = await api.get(`login/email_user/${CURRENT_USER.uuid}`); 28 | currentUser = data; 29 | await chrome.storage.local.set({ CURRENT_USER: currentUser }); 30 | } 31 | 32 | this.currentUser = CURRENT_USER; 33 | }); 34 | } 35 | 36 | getStoredUser() { 37 | return new Promise((resolve) => { 38 | chrome.storage.local.get(async ({ CURRENT_USER }) => { 39 | resolve(CURRENT_USER); 40 | }); 41 | }); 42 | } 43 | 44 | logout() { 45 | chrome.storage.local.clear(); 46 | } 47 | 48 | loginWithGoogle() { 49 | popupCenter({ 50 | url: `${api.urlBase}login/${this.currentUser.uuid}`, 51 | title: 'login', 52 | w: 100, 53 | h: 100, 54 | }); 55 | } 56 | } 57 | 58 | export const auth = new Authentication(); 59 | -------------------------------------------------------------------------------- /chrome-extension/src/assets/scss/alerts.scss: -------------------------------------------------------------------------------- 1 | /* Basic picocss alerts */ 2 | 3 | // import some colors from pico _colors.scss 4 | $amber-50: #fff8e1 !default; 5 | $amber-900: #ff6f00 !default; 6 | $green-50: #e8f5e9 !default; 7 | $green-800: #1b5e20 !default; 8 | $red-50: #ffebee !default; 9 | $red-900: #b71c1c !default; 10 | 11 | // simple picocss alerts 12 | // inherit responsive typography, responsive spacing, icons and size 13 | 14 | .float-bottom { 15 | position: fixed; 16 | width: calc(100% - 25px); 17 | margin-right: 25px; 18 | margin-top: 25px; 19 | bottom: 0; 20 | right: 0; 21 | } 22 | 23 | .alert { 24 | z-index: 1000; 25 | max-width: 400px; 26 | $iconsize: calc( 27 | var(--font-size) * 1.5 28 | ); // 24px / 30px if $enable-responsive-spacings 29 | margin-bottom: var(--spacing); // some default space below alert element 30 | padding: var(--form-element-spacing-vertical) 31 | var(--form-element-spacing-horizontal); // same as forms .input 32 | border-radius: var(--border-radius); 33 | color: var(--color); 34 | background-color: var(--background-color); 35 | border: 1px solid var(--background-color); // compensate for 1px border 36 | 37 | // icon 38 | background-image: var(--icon); 39 | background-position: center left var(--form-element-spacing-vertical); // use vertical for icon left align 40 | background-size: $iconsize auto; 41 | padding-left: calc(var(--form-element-spacing-vertical) * 2 + #{$iconsize}); 42 | } 43 | .alert-danger { 44 | --background-color: #{$red-50}; 45 | --icon: var(--icon-invalid); 46 | --color: #{$red-900}; 47 | } 48 | .alert-warning { 49 | --background-color: #{$amber-50}; 50 | --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='#{rgba(darken($amber-900, 15%), .999)}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); 51 | --color: #{darken($amber-900, 20%)}; 52 | } 53 | .alert-success { 54 | --background-color: #{$green-50}; 55 | --icon: var(--icon-valid); 56 | --color: #{$green-800}; 57 | } 58 | .error-pill { 59 | background-color: #ffebee; 60 | color: #b71c1c; 61 | border-radius: 14px; 62 | padding: 8px 12px 8px 12px; 63 | } 64 | -------------------------------------------------------------------------------- /chrome-extension/src/entry/popup.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from '@/view/popup.vue'; 3 | 4 | import '@picocss/pico'; 5 | import '../assets/scss/alerts.scss'; 6 | import '../assets/scss/transition.scss'; 7 | import '../assets/scss/custom.scss'; 8 | 9 | import { initToken, initUrl } from '@/features/utils'; 10 | import { downloaderStore } from '@/features/store/downloader'; 11 | import { historyStore } from '@/features/store/history'; 12 | 13 | import settings from '@/features/env'; 14 | import Rollbar from 'rollbar'; 15 | 16 | interface InitialData { 17 | token: null | void | string; 18 | url: null | string; 19 | history: null | HistoryType; 20 | downloader: null | Downloader; 21 | } 22 | 23 | export const initialData: InitialData = { 24 | token: null, 25 | url: null, 26 | history: null, 27 | downloader: null, 28 | }; 29 | 30 | (async () => { 31 | // initialize storage data before loading the app 32 | initialData.token = await initToken(); 33 | initialData.url = await initUrl(); 34 | initialData.downloader = await downloaderStore.get(); 35 | initialData.history = await historyStore.get(); 36 | 37 | const app = createApp(App); 38 | 39 | // Set the Rollbar instance in the Vue prototype 40 | // before creating the first Vue instance. 41 | // This ensures it is available in the same way for every 42 | // instance in your app. 43 | app.config.globalProperties.$rollbar = new Rollbar({ 44 | accessToken: settings?.rollbarAccessToken, 45 | captureUncaught: true, 46 | captureUnhandledRejections: true, 47 | payload: { 48 | // Track your events to a specific version of code for better visibility into version health 49 | code_version: '1.0.0', 50 | // Add custom data to your events by adding custom key/value pairs like the one below 51 | custom_data: 'foo', 52 | }, 53 | }); 54 | 55 | // If you have already set up a global error handler, 56 | // just add `vm.$rollbar.error(err)` to the top of it. 57 | // If not, this simple example will preserve the app’s existing 58 | // behavior while also reporting uncaught errors to Rollbar. 59 | app.config.globalProperties.errorHandler = ( 60 | err: any, 61 | vm: any, 62 | info = null, 63 | ) => { 64 | vm.$rollbar.error(err); 65 | console.log(err, info); 66 | throw err; // rethrow 67 | }; 68 | 69 | app.mount('#app'); 70 | })(); 71 | -------------------------------------------------------------------------------- /chrome-extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "repo-inspector", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build-watch": "NODE_ENV=development vite build --watch", 8 | "build": "run-p type-check build-only", 9 | "build-only": "vite build", 10 | "type-check": "vue-tsc --noEmit", 11 | " --- Other --- ": "", 12 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", 13 | "format": "prettier --write src/", 14 | "generate-ts-gql": "graphql-codegen --config graphql.config.yml" 15 | }, 16 | "dependencies": { 17 | "@octokit/core": "^4.2.0", 18 | "@picocss/pico": "^1.5.10", 19 | "apexcharts": "^3.41.0", 20 | "core-js": "^3.8.3", 21 | "graphql": "^16.7.1", 22 | "graphql-tag": "^2.12.6", 23 | "rollbar": "^2.26.0", 24 | "vue": "^3.2.13", 25 | "vue3-apexcharts": "^1.4.4" 26 | }, 27 | "devDependencies": { 28 | "@esbuild-plugins/node-globals-polyfill": "^0.2.3", 29 | "@esbuild-plugins/node-modules-polyfill": "^0.2.2", 30 | "@graphql-codegen/cli": "^5.0.0", 31 | "@graphql-codegen/schema-ast": "^4.0.0", 32 | "@graphql-codegen/typed-document-node": "^5.0.1", 33 | "@graphql-codegen/typescript": "^4.0.1", 34 | "@graphql-codegen/typescript-operations": "^4.0.1", 35 | "@graphql-codegen/typescript-resolvers": "^4.0.1", 36 | "@graphql-typed-document-node/core": "^3.2.0", 37 | "@types/chrome": "^0.0.233", 38 | "@typescript-eslint/eslint-plugin": "^5.59.0", 39 | "@typescript-eslint/parser": "^5.59.0", 40 | "@vitejs/plugin-vue": "^4.1.0", 41 | "@vitejs/plugin-vue-jsx": "^3.0.1", 42 | "eslint": "^7.32.0", 43 | "eslint-config-airbnb-base": "^15.0.0", 44 | "eslint-config-airbnb-typescript": "^17.0.0", 45 | "eslint-config-prettier": "^8.8.0", 46 | "eslint-import-resolver-alias": "^1.1.2", 47 | "eslint-plugin-import": "^2.27.5", 48 | "eslint-plugin-prettier": "^4.2.1", 49 | "eslint-plugin-vue": "^8.0.3", 50 | "graphql-operations-string-loader": "^1.0.2", 51 | "npm-run-all": "^4.1.5", 52 | "prettier": "^2.8.7", 53 | "rollup-plugin-node-polyfills": "^0.2.1", 54 | "sass": "^1.60.0", 55 | "typescript": "^5.0.4", 56 | "vite": "^4.3.0", 57 | "vue-eslint-parser": "^9.1.1", 58 | "vue-tsc": "^1.2.0" 59 | }, 60 | "browserslist": [ 61 | "> 1%", 62 | "last 2 versions", 63 | "not dead", 64 | "not ie 11" 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /chrome-extension/src/assets/scss/custom.scss: -------------------------------------------------------------------------------- 1 | /* Lime Light scheme (Default) */ 2 | /* Can be forced with data-theme="light" */ 3 | [data-theme='light'], 4 | :root:not([data-theme='dark']) { 5 | --primary: #32cc92; 6 | --primary-hover: #83fdcf; 7 | --primary-focus: rgba(192, 202, 51, 0.125); 8 | --primary-inverse: rgba(0, 0, 0, 0.75); 9 | } 10 | 11 | /* Lime Dark scheme (Auto) */ 12 | /* Automatically enabled if user has Dark mode enabled */ 13 | @media only screen and (prefers-color-scheme: dark) { 14 | :root:not([data-theme='light']) { 15 | --primary: #32cc92; 16 | --primary-hover: #83fdcf; 17 | --primary-focus: rgba(192, 202, 51, 0.25); 18 | --primary-inverse: rgba(0, 0, 0, 0.75); 19 | } 20 | } 21 | 22 | /* Lime Dark scheme (Forced) */ 23 | /* Enabled if forced with data-theme="dark" */ 24 | [data-theme='dark'] { 25 | --primary: #32cc92; 26 | --primary-hover: #83fdcf; 27 | --primary-focus: rgba(192, 202, 51, 0.25); 28 | --primary-inverse: rgba(0, 0, 0, 0.75); 29 | } 30 | 31 | /* Lime (Common styles) */ 32 | :root { 33 | --form-element-active-border-color: var(--primary); 34 | --form-element-focus-color: var(--primary-focus); 35 | --switch-color: var(--primary-inverse); 36 | --switch-checked-background-color: var(--primary); 37 | } 38 | 39 | .tooltip-container { 40 | position: relative; 41 | display: inline-block; 42 | 43 | &:hover { 44 | cursor: help; 45 | } 46 | } 47 | 48 | .tooltip { 49 | position: absolute; 50 | z-index: 99; 51 | bottom: 100%; 52 | left: 50%; 53 | padding: 0.5rem; 54 | transform: translate(-50%, -0.25rem); 55 | border-radius: 0.25rem; 56 | background: hsl(205, 30%, 15%); 57 | color: #fff; 58 | font-style: normal; 59 | font-weight: 400; 60 | font-size: 0.875rem; 61 | text-decoration: none; 62 | white-space: pre-line; 63 | pointer-events: none; 64 | width: max-content; 65 | display: none; 66 | 67 | li, 68 | p { 69 | color: #fff; 70 | font-size: 0.75rem; 71 | line-height: 120%; 72 | } 73 | 74 | p { 75 | margin-bottom: 16px; 76 | max-width: 300px; 77 | } 78 | 79 | ul { 80 | margin-bottom: 0; 81 | } 82 | } 83 | 84 | .tooltip::after { 85 | content: ''; 86 | position: absolute; 87 | bottom: 0%; 88 | left: 50%; 89 | transform: translate(-50%, 100%); 90 | border-width: 6px; 91 | border-style: solid; 92 | border-color: #1b2832 transparent transparent transparent; 93 | } 94 | 95 | .tooltip-container:hover .tooltip { 96 | display: block; 97 | } 98 | -------------------------------------------------------------------------------- /chrome-extension/src/features/store/notification.ts: -------------------------------------------------------------------------------- 1 | import { NOTIFICATION_MODEL } from './models'; 2 | 3 | export const NOTIFICATION_TYPES = { 4 | SUCCESS: 'success', 5 | ERROR: 'error', 6 | }; 7 | 8 | export class NotificationStore { 9 | constructor() { 10 | chrome.storage.local.get(async ({ NOTIFICATION_STORE }) => { 11 | if (!NOTIFICATION_STORE) { 12 | chrome.storage.local.set({ NOTIFICATION_STORE: NOTIFICATION_MODEL }); 13 | } 14 | }); 15 | } 16 | 17 | async get(): Promise { 18 | const { NOTIFICATION_STORE = [] } = (await chrome.storage.local.get()) as { 19 | NOTIFICATION_STORE: NotificationType[]; 20 | }; 21 | 22 | return NOTIFICATION_STORE; 23 | } 24 | 25 | async set(value: NotificationType) { 26 | const NOTIFICATION_STORE = await this.get(); 27 | 28 | await chrome.storage.local.set({ 29 | NOTIFICATION_STORE: [value, ...NOTIFICATION_STORE], 30 | }); 31 | } 32 | 33 | async remove(i: number) { 34 | // remove data for url in NOTIFICATION_STORE 35 | const NOTIFICATION_STORE = await this.get(); 36 | 37 | NOTIFICATION_STORE.splice(i, 1); 38 | 39 | await chrome.storage.local.set({ NOTIFICATION_STORE }); 40 | } 41 | 42 | async checkTabFocused( 43 | document: Document, 44 | showMessage: (v: string) => void, 45 | showError: (v: string) => void, 46 | ) { 47 | // check if tab is focused and show notifications 48 | if (document.visibilityState === 'visible') { 49 | const notifications = await this.get(); 50 | 51 | for (let i = 0; i < notifications.length; i++) { 52 | const notification = notifications[i]; 53 | 54 | if (notification.type === NOTIFICATION_TYPES.ERROR) { 55 | showError(notification.message); 56 | } else if (notification.type === NOTIFICATION_TYPES.SUCCESS) { 57 | showMessage(notification.message); 58 | } 59 | 60 | await this.remove(i); 61 | } 62 | } 63 | } 64 | 65 | initTabFocusListener( 66 | document: Document, 67 | showMessage: (v: string) => void, 68 | showError: (v: string) => void, 69 | ) { 70 | // check if tab is focused and show notifications 71 | const runCheckTabFocused = async () => { 72 | this.checkTabFocused(document, showMessage, showError); 73 | }; 74 | 75 | // init listener for tab focus 76 | document.addEventListener('visibilitychange', runCheckTabFocused); 77 | } 78 | } 79 | 80 | export const notificationStore = new NotificationStore(); 81 | -------------------------------------------------------------------------------- /chrome-extension/src/features/utils/serializeUser.ts: -------------------------------------------------------------------------------- 1 | import type { Octokit } from '@octokit/core'; 2 | import { getLocation } from './getLocation'; 3 | 4 | export const serializeUser = async ( 5 | user: GithubUser, 6 | octokit: Octokit, 7 | isExtendLocation = false, 8 | ) => { 9 | const { 10 | bio, 11 | company, 12 | createdAt, 13 | email, 14 | isHireable, 15 | isSiteAdmin, 16 | location, 17 | login, 18 | name, 19 | twitterUsername, 20 | updatedAt, 21 | websiteUrl, 22 | __typename, 23 | followers, 24 | following, 25 | } = user; 26 | 27 | let event_count; 28 | let lastEventDate; 29 | 30 | try { 31 | // get user event count 32 | const eventsURL = `https://api.github.com/users/${login}`; 33 | const { data } = await octokit.request( 34 | `GET ${eventsURL}/events?per_page=100`, 35 | ); 36 | 37 | event_count = data.length; 38 | lastEventDate = data.length && data[0]?.created_at; 39 | } catch (error) { 40 | console.log(error); 41 | } 42 | 43 | // define real users 44 | const real_user = event_count > 3 || followers?.totalCount > 3; 45 | 46 | // define active user 47 | const yearAgo = new Date( 48 | new Date().setFullYear(new Date().getFullYear() - 1), 49 | ).getTime(); 50 | const active_user = 51 | event_count > 0 && 52 | lastEventDate && 53 | new Date(lastEventDate).getTime() > yearAgo; 54 | 55 | const serializedUser = { 56 | login, 57 | type: __typename, 58 | site_admin: isSiteAdmin, 59 | name, 60 | company, 61 | blog: websiteUrl, 62 | location, 63 | email, 64 | hireable: isHireable, 65 | bio, 66 | twitter_username: twitterUsername, 67 | followers: followers?.totalCount, 68 | following: following?.totalCount, 69 | created_at: createdAt, 70 | updated_at: updatedAt, 71 | country: '', 72 | lat: null, 73 | lon: null, 74 | event_count, 75 | real_user, 76 | active_user, 77 | }; 78 | 79 | // get location data from the github location str 80 | if (location && isExtendLocation) { 81 | try { 82 | const locationData = await getLocation(location); 83 | 84 | serializedUser.country = locationData.features[0]?.properties?.country; 85 | serializedUser.lat = locationData.features[0]?.geometry?.coordinates[0]; 86 | serializedUser.lon = locationData.features[0]?.geometry?.coordinates[1]; 87 | } catch (error) { 88 | console.log(error); 89 | } 90 | } 91 | 92 | return serializedUser; 93 | }; 94 | -------------------------------------------------------------------------------- /chrome-extension/src/entry/options.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from '@/view/options.vue'; 3 | 4 | import { historyStore } from '@/features/store/history'; 5 | import { initToken } from '@/features/utils'; 6 | 7 | import '@picocss/pico'; 8 | import '../assets/scss/alerts.scss'; 9 | import '../assets/scss/transition.scss'; 10 | import '../assets/scss/custom.scss'; 11 | 12 | import { downloaderStore } from '@/features/store/downloader'; 13 | 14 | import settings from '@/features/env'; 15 | import Rollbar from 'rollbar'; 16 | 17 | interface InitialData { 18 | token: null | void | string; 19 | history: null | HistoryType; 20 | downloader: null | Downloader; 21 | } 22 | 23 | export const initialData: InitialData = { 24 | token: null, 25 | history: null, 26 | downloader: null, 27 | }; 28 | 29 | (async () => { 30 | // initialize storage data before loading the app 31 | initialData.token = await initToken(); 32 | initialData.history = await historyStore.get(); 33 | initialData.downloader = await downloaderStore.get(); 34 | 35 | const app = createApp(App); 36 | 37 | // Set the Rollbar instance in the Vue prototype 38 | // before creating the first Vue instance. 39 | // This ensures it is available in the same way for every 40 | // instance in your app. 41 | app.config.globalProperties.$rollbar = new Rollbar({ 42 | accessToken: settings?.rollbarAccessToken, 43 | captureUncaught: true, 44 | captureUnhandledRejections: true, 45 | payload: { 46 | // Track your events to a specific version of code for better visibility into version health 47 | code_version: '1.0.0', 48 | // Add custom data to your events by adding custom key/value pairs like the one below 49 | custom_data: 'foo', 50 | }, 51 | }); 52 | 53 | // If you have already set up a global error handler, 54 | // just add `vm.$rollbar.error(err)` to the top of it. 55 | // If not, this simple example will preserve the app’s existing 56 | // behavior while also reporting uncaught errors to Rollbar. 57 | app.config.globalProperties.errorHandler = ( 58 | err: any, 59 | vm: any, 60 | info = null, 61 | ) => { 62 | vm.$rollbar.error(err); 63 | console.log(err, info); 64 | throw err; // rethrow 65 | }; 66 | 67 | app.mount('#app'); 68 | })(); 69 | 70 | // prevent users from closing download page by mistake 71 | const preventClose = (e: BeforeUnloadEvent) => { 72 | e.preventDefault(); 73 | e.returnValue = ''; 74 | 75 | return true; 76 | }; 77 | 78 | window.addEventListener('beforeunload', preventClose, true); 79 | -------------------------------------------------------------------------------- /server/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from config import settings 7 | from models import Repository 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | config.set_main_option('sqlalchemy.url', settings.DATABASE_URL) 15 | 16 | # Interpret the config file for Python logging. 17 | # This line sets up loggers basically. 18 | if config.config_file_name is not None: 19 | fileConfig(config.config_file_name) 20 | 21 | # add your model's MetaData object here 22 | # for 'autogenerate' support 23 | # from myapp import mymodel 24 | # target_metadata = mymodel.Base.metadata 25 | target_metadata = Repository.metadata 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def run_migrations_offline() -> None: 34 | """Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | url = config.get_main_option("sqlalchemy.url") 46 | context.configure( 47 | url=url, 48 | target_metadata=target_metadata, 49 | literal_binds=True, 50 | dialect_opts={"paramstyle": "named"}, 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online() -> None: 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | connectable = engine_from_config( 65 | config.get_section(config.config_ini_section, {}), 66 | prefix="sqlalchemy.", 67 | poolclass=pool.NullPool, 68 | ) 69 | 70 | with connectable.connect() as connection: 71 | context.configure( 72 | connection=connection, target_metadata=target_metadata 73 | ) 74 | 75 | with context.begin_transaction(): 76 | context.run_migrations() 77 | 78 | 79 | if context.is_offline_mode(): 80 | run_migrations_offline() 81 | else: 82 | run_migrations_online() 83 | -------------------------------------------------------------------------------- /chrome-extension/src/view/components/StatisticChart.vue: -------------------------------------------------------------------------------- 1 | 127 | 128 | 133 | -------------------------------------------------------------------------------- /chrome-extension/vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'url'; 2 | 3 | import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'; 4 | import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill'; 5 | import rollupNodePolyFill from 'rollup-plugin-node-polyfills'; 6 | 7 | import { defineConfig } from 'vite'; 8 | import vue from '@vitejs/plugin-vue'; 9 | import vueJsx from '@vitejs/plugin-vue-jsx'; 10 | 11 | // https://vitejs.dev/config/ 12 | export default defineConfig({ 13 | plugins: [vue(), vueJsx()], 14 | resolve: { 15 | alias: { 16 | '@': fileURLToPath(new URL('./src', import.meta.url)), 17 | 18 | // This Rollup aliases are extracted from @esbuild-plugins/node-modules-polyfill, 19 | // see https://github.com/remorses/esbuild-plugins/blob/master/node-modules-polyfill/src/polyfills.ts 20 | // process and buffer are excluded because already managed 21 | // by node-globals-polyfill 22 | util: 'rollup-plugin-node-polyfills/polyfills/util', 23 | sys: 'util', 24 | events: 'rollup-plugin-node-polyfills/polyfills/events', 25 | stream: 'rollup-plugin-node-polyfills/polyfills/stream', 26 | path: 'rollup-plugin-node-polyfills/polyfills/path', 27 | querystring: 'rollup-plugin-node-polyfills/polyfills/qs', 28 | punycode: 'rollup-plugin-node-polyfills/polyfills/punycode', 29 | url: 'rollup-plugin-node-polyfills/polyfills/url', 30 | string_decoder: 'rollup-plugin-node-polyfills/polyfills/string-decoder', 31 | http: 'rollup-plugin-node-polyfills/polyfills/http', 32 | https: 'rollup-plugin-node-polyfills/polyfills/http', 33 | os: 'rollup-plugin-node-polyfills/polyfills/os', 34 | assert: 'rollup-plugin-node-polyfills/polyfills/assert', 35 | constants: 'rollup-plugin-node-polyfills/polyfills/constants', 36 | _stream_duplex: 37 | 'rollup-plugin-node-polyfills/polyfills/readable-stream/duplex', 38 | _stream_passthrough: 39 | 'rollup-plugin-node-polyfills/polyfills/readable-stream/passthrough', 40 | _stream_readable: 41 | 'rollup-plugin-node-polyfills/polyfills/readable-stream/readable', 42 | _stream_writable: 43 | 'rollup-plugin-node-polyfills/polyfills/readable-stream/writable', 44 | _stream_transform: 45 | 'rollup-plugin-node-polyfills/polyfills/readable-stream/transform', 46 | timers: 'rollup-plugin-node-polyfills/polyfills/timers', 47 | console: 'rollup-plugin-node-polyfills/polyfills/console', 48 | vm: 'rollup-plugin-node-polyfills/polyfills/vm', 49 | zlib: 'rollup-plugin-node-polyfills/polyfills/zlib', 50 | tty: 'rollup-plugin-node-polyfills/polyfills/tty', 51 | domain: 'rollup-plugin-node-polyfills/polyfills/domain', 52 | buffer: 'rollup-plugin-node-polyfills/polyfills/buffer-es6', 53 | process: 'rollup-plugin-node-polyfills/polyfills/process-es6', 54 | }, 55 | }, 56 | define: { global: 'window' }, 57 | optimizeDeps: { 58 | esbuildOptions: { 59 | // Node.js global to browser globalThis 60 | define: { 61 | global: 'globalThis', 62 | }, 63 | // Enable esbuild polyfill plugins 64 | plugins: [ 65 | NodeGlobalsPolyfillPlugin({ 66 | process: true, 67 | buffer: true, 68 | }), 69 | NodeModulesPolyfillPlugin(), 70 | ], 71 | }, 72 | }, 73 | build: { 74 | rollupOptions: { 75 | plugins: [ 76 | // Enable rollup polyfills plugin 77 | // used during production bundling 78 | rollupNodePolyFill(), 79 | ], 80 | input: { 81 | background: './background.html', 82 | options: './options.html', 83 | popup: './popup.html', 84 | }, 85 | }, 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /server/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | # output_encoding = utf-8 62 | 63 | sqlalchemy.url = driver://user:pass@localhost/dbname 64 | 65 | 66 | [post_write_hooks] 67 | # post_write_hooks defines scripts or Python functions that are run 68 | # on newly generated revision scripts. See the documentation for further 69 | # detail and examples 70 | 71 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 72 | # hooks = black 73 | # black.type = console_scripts 74 | # black.entrypoint = black 75 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 76 | 77 | # Logging configuration 78 | [loggers] 79 | keys = root,sqlalchemy,alembic 80 | 81 | [handlers] 82 | keys = console 83 | 84 | [formatters] 85 | keys = generic 86 | 87 | [logger_root] 88 | level = WARN 89 | handlers = console 90 | qualname = 91 | 92 | [logger_sqlalchemy] 93 | level = WARN 94 | handlers = 95 | qualname = sqlalchemy.engine 96 | 97 | [logger_alembic] 98 | level = INFO 99 | handlers = 100 | qualname = alembic 101 | 102 | [handler_console] 103 | class = StreamHandler 104 | args = (sys.stderr,) 105 | level = NOTSET 106 | formatter = generic 107 | 108 | [formatter_generic] 109 | format = %(levelname)-5.5s [%(name)s] %(message)s 110 | datefmt = %H:%M:%S 111 | -------------------------------------------------------------------------------- /chrome-extension/src/view/components/DownloadCard.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 127 | 128 | 179 | -------------------------------------------------------------------------------- /chrome-extension/src/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | 3 | type Progress = { 4 | current: number; 5 | max: number; 6 | }; 7 | 8 | type Settings = { 9 | stars: boolean; 10 | forks: boolean; 11 | location: boolean; 12 | sample: boolean; 13 | samplePercent: number; 14 | }; 15 | 16 | type RepoParams = { 17 | owner: string; 18 | name: string; 19 | }; 20 | 21 | type LastStage = 'stargazers' | 'forks' | 'additional'; 22 | 23 | type TotalRatingWeights = { 24 | starsWeight: number; 25 | contributorsWeight: number; 26 | starsGrowthWeight: number; 27 | starsActivityWeight: number; 28 | forksStarsWeight: number; 29 | issuesOpenedLTMWeight: number; 30 | issuesClosedLTMWeight: number; 31 | PRMergedLTMWeight: number; 32 | }; 33 | 34 | type Downloader = { 35 | id: null | string; 36 | active: boolean; 37 | stage: number; 38 | date: null | string | number | Date; 39 | url: string; 40 | name: string; 41 | progress: Progress; 42 | stargazers_count: number; 43 | stargazers_users: number; 44 | stargazers_users_data?: DBUser[]; 45 | forks_count: number; 46 | forks_users: number; 47 | forks_users_data?: DBUser[]; 48 | issues_count: number; 49 | pull_requests_count: number; 50 | watchers_count: number; 51 | contributors_count: number; 52 | octokitUrl: string; 53 | settings: Settings; 54 | issues_statistic?: IssuesStatistic; 55 | stars_history?: StarHistoryByMonth; 56 | lastMonthStars?: number; 57 | prsMergedLTM?: number; 58 | lastStage?: LastStage; 59 | cursor?: string | null; 60 | restoreLimitsDate?: Date; 61 | total_rating?: number; 62 | totalRatingWeights: TotalRatingWeights; 63 | }; 64 | 65 | type HistoryType = Downloader[]; 66 | 67 | type CurrentUser = { 68 | uuid?: string; 69 | email?: string; 70 | }; 71 | 72 | type NotificationType = { 73 | type: string; 74 | message: string; 75 | }; 76 | 77 | type StargazerUserResponse = { 78 | repository: { 79 | stargazers: { 80 | edges: StargazerUser[]; 81 | pageInfo: PageInfo; 82 | }; 83 | }; 84 | rateLimit: RateLimit; 85 | }; 86 | 87 | type StargazerUser = { 88 | node: GithubUser; 89 | }; 90 | 91 | type UserActivityNode = { 92 | node: { 93 | createdAt: Date; 94 | }; 95 | }; 96 | 97 | type RepositoryNode = { 98 | node: { 99 | name: string; 100 | }; 101 | }; 102 | 103 | type FollowerNode = { 104 | node: { 105 | login: string; 106 | }; 107 | }; 108 | 109 | type RateLimit = { 110 | cost: number; 111 | remaining: number; 112 | resetAt: Date; 113 | }; 114 | 115 | type PageInfo = { 116 | endCursor: string; 117 | hasNextPage: boolean; 118 | }; 119 | 120 | type UserResponse = StargazerUserResponse & ForkUserResponse; 121 | 122 | type ForkUserResponse = { 123 | repository: { 124 | forks: { 125 | edges: ForkUser[]; 126 | pageInfo: PageInfo; 127 | }; 128 | }; 129 | rateLimit: RateLimit; 130 | }; 131 | 132 | type ForkUser = { 133 | node: { 134 | owner: GithubUser; 135 | }; 136 | }; 137 | 138 | type GithubUser = { 139 | createdAt: Date; 140 | updatedAt: Date; 141 | location: string | null; 142 | login: string; 143 | name: string | null; 144 | bio: string; 145 | company: string; 146 | email: string; 147 | isHireable: boolean; 148 | isSiteAdmin: boolean; 149 | twitterUsername: string; 150 | websiteUrl: string; 151 | __typename: string; 152 | followers: { 153 | totalCount: number; 154 | }; 155 | following: { 156 | totalCount: number; 157 | }; 158 | }; 159 | 160 | type DBUser = { 161 | active_user: boolean; 162 | bio: string; 163 | blog: string; 164 | company: string; 165 | country: string; 166 | created_at: Date; 167 | email: string; 168 | event_count: number; 169 | followers: number; 170 | following: number; 171 | hireable: boolean; 172 | lat: number | null; 173 | location: string | null; 174 | login: string; 175 | lon: number | null; 176 | name: string | null; 177 | real_user: boolean; 178 | site_admin: boolean; 179 | twitter_username: string; 180 | type: string; 181 | updated_at: Date; 182 | }; 183 | 184 | type Issue = { 185 | node: { 186 | createdAt: Date; 187 | closed: boolean; 188 | state: 'OPEN' | 'CLOSED'; 189 | closedAt: Date | null; 190 | }; 191 | }; 192 | 193 | type ChartData = { 194 | [key: string]: { 195 | opened: number; 196 | closed: number; 197 | }; 198 | }; 199 | 200 | type IssuesStatistic = { 201 | chartData?: ChartData; 202 | health?: number; 203 | openedLTM?: number; 204 | closedLTM?: number; 205 | }; 206 | 207 | type PullRequest = { 208 | node: { 209 | createdAt: Date; 210 | closed: boolean; 211 | state: 'OPEN' | 'CLOSED'; 212 | closedAt: Date | null; 213 | }; 214 | }; 215 | 216 | type StarHistory = { 217 | starredAt: Date; 218 | node: { 219 | login: string; 220 | }; 221 | }; 222 | 223 | type StarHistoryByMonth = { 224 | [key: string]: { 225 | count: number; 226 | }; 227 | }; 228 | 229 | type InspectData = { 230 | fork_users: DBUser[]; 231 | issues: IssuesStatistic; 232 | pull_requests_merged_LTM: number; 233 | stargaze_users: DBUser[]; 234 | stars_history: StarHistoryByMonth; 235 | lastMonthStars: number; 236 | }; 237 | 238 | type DataForRating = { 239 | stars?: number; 240 | contributors: number; 241 | starsGrowth?: number; 242 | starsActivity?: number; 243 | forksStars?: number; 244 | issuesOpenedLTM: number; 245 | issuesClosedLTM: number; 246 | pmMergedLTM: number; 247 | }; 248 | -------------------------------------------------------------------------------- /chrome-extension/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "webextensions": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "prettier" 9 | ], 10 | "overrides": [ 11 | { 12 | "files": "**/*.+(ts|tsx|vue)", 13 | "excludedFiles": "**/*.+(js|jsx)", 14 | "parser": "vue-eslint-parser", 15 | "parserOptions": { 16 | "parser": "@typescript-eslint/parser", 17 | "project": "./tsconfig.json", 18 | "sourceType": "module", 19 | "extraFileExtensions": [ 20 | ".vue" 21 | ] 22 | }, 23 | "extends": [ 24 | "eslint:recommended", 25 | "plugin:vue/vue3-recommended", 26 | "airbnb-base", 27 | "airbnb-typescript/base", 28 | "prettier" 29 | ], 30 | "plugins": [ 31 | "@typescript-eslint", 32 | "prettier" 33 | ], 34 | "rules": { 35 | // Enable Prettier rules from .prettierrc.js 36 | "prettier/prettier": [ 37 | "error", 38 | { 39 | "singleQuote": true, 40 | "trailingComma": "all" 41 | } 42 | ], 43 | // ********** 44 | // *** Plain JavaScript 45 | // ********** 46 | // Sorting imported files 47 | "sort-imports": [ 48 | "error", 49 | { 50 | "ignoreCase": true, 51 | "ignoreDeclarationSort": true, 52 | "ignoreMemberSort": false, 53 | "memberSyntaxSortOrder": [ 54 | "none", 55 | "all", 56 | "single", 57 | "multiple" 58 | ] 59 | } 60 | ], 61 | // (A -> B -> A) imports 62 | "import/no-cycle": 0, 63 | // Be able to declare variables even if you have the other vars with the same name in upper scope(useful for Redux action creators) 64 | "no-shadow": 0, 65 | // Disable console.log in code 66 | "no-console": 0, 67 | // When there is only a single export from a module, prefer using default export over named export. 68 | // I think we can export some objects withut default export, keping in mind that there will be more export sfrom this file in a future 69 | "import/prefer-default-export": 0, 70 | // We want to enable i++ or i-- 71 | "no-plusplus": 0, 72 | // May need for some internal variables like __REDUX_DEVTOOLS_EXTENSION__ 73 | "no-underscore-dangle": 0, 74 | // Sometimes we may need it - like for asyncForEach function 75 | "no-await-in-loop": 0, 76 | // We may use dynamic require in Node.js 77 | "import/no-dynamic-require": 0, 78 | "global-require": 0, 79 | // We may have some not-camelCase vars when we receive a response from API 80 | "camelcase": 0, 81 | "import/extensions": 0, 82 | // We may have alerts on Chrome extension 83 | "no-alert": 0, 84 | // $FixMe: Temporary disable some rules causing errors 85 | "class-methods-use-this": 0, 86 | // https://github.com/vuejs/vue-eslint-parser/issues/99 87 | "no-undef": 0, 88 | "@typescript-eslint/naming-convention": 0, 89 | // https://eslint.org/docs/latest/rules/newline-before-return 90 | "newline-before-return": "error", 91 | // https://eslint.org/docs/latest/rules/max-statements-per-line 92 | "max-statements-per-line": [ 93 | "error", 94 | { 95 | "max": 1 96 | } 97 | ], 98 | // https://eslint.org/docs/latest/rules/padding-line-between-statements 99 | "padding-line-between-statements": [ 100 | "error", 101 | { 102 | "blankLine": "always", 103 | "prev": "block-like", 104 | "next": "*" 105 | }, 106 | { 107 | "blankLine": "always", 108 | "prev": "*", 109 | "next": "block-like" 110 | } 111 | ] 112 | } 113 | } 114 | ], 115 | "settings": { 116 | // Absolute paths 117 | "import/resolver": { 118 | "alias": { 119 | "map": [ 120 | [ 121 | "@", 122 | "./src" 123 | ] 124 | ], 125 | "extensions": [ 126 | ".ts", 127 | ".tsx", 128 | ".json", 129 | ".vue" 130 | ] 131 | }, 132 | "node": { 133 | "paths": "." 134 | } 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /server/static/css/report.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | body { 7 | background-color: #fff; 8 | font-size: 26px; 9 | font-family: 'Brutal Type W00 Light'; 10 | } 11 | 12 | .report { 13 | width: 100%; 14 | } 15 | 16 | .report-header { 17 | width: 100%; 18 | position: relative; 19 | margin-bottom: 30px; 20 | } 21 | 22 | .report-header__main-logo { 23 | width: 480px; 24 | } 25 | 26 | .report-header__extra-logo { 27 | width: 150px; 28 | position: absolute; 29 | right: 0; 30 | } 31 | 32 | .report-text { 33 | font-family: 'Brutal Type'; 34 | font-size: 26px; 35 | color: #000000; 36 | font-weight: 500; 37 | margin: 0; 38 | padding: 0; 39 | display: inline-block; 40 | } 41 | 42 | .report-text_nowrap { 43 | white-space: nowrap; 44 | } 45 | 46 | .report-text_mb_30 { 47 | margin-bottom: 30px; 48 | } 49 | 50 | .report-text_mb_15 { 51 | margin-bottom: 15px; 52 | } 53 | 54 | .report-text_blue { 55 | color: #2540f2; 56 | } 57 | 58 | .report-repo-link, 59 | .report-star-link { 60 | color: #2540f2; 61 | font-family: 'Brutal Type W00 Light'; 62 | text-decoration: none; 63 | white-space: nowrap; 64 | } 65 | 66 | .report-star-link { 67 | color: #5e23f3; 68 | } 69 | 70 | .report-field { 71 | width: 100%; 72 | position: relative; 73 | margin: 0; 74 | padding: 0; 75 | } 76 | 77 | .report-field_mb_15 { 78 | margin-bottom: 15px; 79 | } 80 | 81 | .report-field_mb_30 { 82 | margin-bottom: 30px; 83 | } 84 | 85 | .report-field_mb_100 { 86 | margin-bottom: 100px; 87 | } 88 | 89 | .report-summary__text { 90 | display: inline; 91 | } 92 | 93 | .report-settings { 94 | position: absolute; 95 | right: 0; 96 | top: 2px; 97 | } 98 | 99 | .report-settings__item { 100 | display: inline-block; 101 | margin-left: 10px; 102 | } 103 | 104 | .report-settings__text { 105 | display: inline-block; 106 | color: #000000; 107 | } 108 | 109 | .report-settings__icon { 110 | display: inline-block; 111 | width: 18px; 112 | height: 18px; 113 | object-fit: cover; 114 | object-position: center; 115 | margin-right: 5px; 116 | } 117 | 118 | .report-value { 119 | position: absolute; 120 | right: 0; 121 | top: 3px; 122 | max-width: max-content; 123 | } 124 | 125 | .report-value_blue { 126 | color: #2335f3; 127 | } 128 | 129 | .report-progressbar { 130 | width: 100%; 131 | } 132 | 133 | .report-progressbar_mb_30 { 134 | margin-bottom: 30px; 135 | } 136 | 137 | .report-progressbar_mb_40 { 138 | margin-bottom: 40px; 139 | } 140 | 141 | .report-progressbar__header { 142 | width: 100%; 143 | position: relative; 144 | margin-bottom: 15px; 145 | } 146 | 147 | .report-progressbar__track { 148 | box-sizing: border-box; 149 | width: 100%; 150 | height: 30px; 151 | padding: 3px; 152 | background-color: #fff; 153 | border: 2px solid #2335f3; 154 | border-radius: 30px; 155 | } 156 | 157 | .report-progressbar__progress { 158 | height: 100%; 159 | background-color: #2335f3; 160 | border-radius: 24px; 161 | } 162 | 163 | .report-stars-chart { 164 | width: 100%; 165 | margin-bottom: 15px; 166 | } 167 | 168 | .report-info-item { 169 | width: max-content; 170 | max-width: max-content; 171 | padding: 5px 15px; 172 | box-sizing: border-box; 173 | border-radius: 30px; 174 | border: 2px solid #2335f3; 175 | display: inline-block; 176 | } 177 | 178 | .report-info-item_left { 179 | position: absolute; 180 | left: 0; 181 | top: 0; 182 | } 183 | 184 | .report-info-item_right { 185 | position: absolute; 186 | right: 0; 187 | top: 0; 188 | } 189 | 190 | .report-info-item_ml_20 { 191 | margin-left: 20px; 192 | } 193 | 194 | .report-info-item__text { 195 | color: #2335f3; 196 | font-family: 'Brutal Type'; 197 | font-size: 20px; 198 | font-weight: 500; 199 | white-space: nowrap; 200 | } 201 | 202 | .report-info-item__value { 203 | font-family: 'Brutal Type W00 Light'; 204 | } 205 | 206 | .report-field-item-group { 207 | width: max-content; 208 | position: absolute; 209 | right: 0; 210 | top: -6px; 211 | } 212 | 213 | .report-issues-chart { 214 | width: 100%; 215 | margin-bottom: 35px; 216 | } 217 | 218 | .report-radial-charts-group { 219 | width: 100%; 220 | margin-bottom: 50px; 221 | } 222 | 223 | .report-radial-chart-wrapper { 224 | width: 350px; 225 | height: 350px; 226 | position: relative; 227 | display: inline-block; 228 | } 229 | 230 | .report-radial-chart-chart { 231 | width: 100%; 232 | height: 100%; 233 | min-height: 350px; 234 | object-position: center; 235 | object-fit: cover; 236 | } 237 | 238 | .report-radial-chart-wrapper_ml { 239 | margin-right: 0; 240 | margin-left: 21%; 241 | } 242 | 243 | .report-radial-chart-content { 244 | text-align: center; 245 | position: absolute; 246 | left: 3%; 247 | right: 0; 248 | top: 35%; 249 | } 250 | 251 | .report-radial-chart-content__title, 252 | .report-radial-chart-content__text { 253 | font-size: 26px; 254 | color: #2335f3; 255 | } 256 | 257 | .report-radial-chart-content__title { 258 | font-family: 'Brutal Type'; 259 | font-weight: 500; 260 | margin-bottom: 10px; 261 | } 262 | 263 | .report-radial-chart-content__text { 264 | font-family: 'Brutal Type W00 Light'; 265 | } 266 | 267 | .report-location { 268 | width: 100%; 269 | vertical-align: top; 270 | } 271 | 272 | .report-location-wrapper { 273 | display: inline-block; 274 | width: 50%; 275 | vertical-align: top; 276 | } 277 | 278 | .report-location-text { 279 | white-space: nowrap; 280 | font-size: 18px; 281 | margin-bottom: 5px; 282 | color: #272727; 283 | } 284 | -------------------------------------------------------------------------------- /server/routes.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from fastapi import APIRouter, Request, Form, BackgroundTasks 3 | from typing import List 4 | from db import ActiveSession 5 | from sqlmodel import Session 6 | from app import templates 7 | from fastapi.responses import HTMLResponse 8 | from google.oauth2 import id_token 9 | from google.auth.transport import requests 10 | from config import settings 11 | from fastapi.exceptions import HTTPException 12 | from utils.message import MessageCreator 13 | from utils.sender import email_sender 14 | import models 15 | import os 16 | 17 | repository_router = APIRouter() 18 | 19 | @repository_router.get("/{repo_id}/resend/") 20 | def resend(repo_id, user_id: uuid.UUID, session: Session = ActiveSession): 21 | # resend email for repo_id 22 | email_user = session.query(models.EmailUser).where(models.EmailUser.uuid == user_id).first() 23 | repository = session.query(models.Repository).where( 24 | models.Repository.id == repo_id, 25 | models.Repository.email_user_id == email_user.id).first() 26 | 27 | # prevent access to other users repositories 28 | if not repository: 29 | raise HTTPException(status_code=404, detail="Repository not found") 30 | 31 | # send email 32 | msg_creator = MessageCreator(session, repo_id) 33 | message_text, archive_path = msg_creator.create_message() 34 | email_sender.send_message( 35 | subject=f"Repository summary for {repository.name}", 36 | message=message_text, 37 | recipient=email_user, 38 | file_path=archive_path, 39 | ) 40 | msg_creator.clear_temp() 41 | return "success" 42 | 43 | 44 | def process_repository(forks: list, stargazers: list, user_id: uuid.UUID, repository: models.Repository, session: Session): 45 | # assign forks and stargazers to repository 46 | for fork in forks: 47 | fork.repository_id = repository.id 48 | fork.interaction_type = models.INTERACTION_TYPE.FORK 49 | db_fork = models.RepositoryUser.from_orm(fork) 50 | session.add(db_fork) 51 | for stargazer in stargazers: 52 | stargazer.repository_id = repository.id 53 | stargazer.interaction_type = models.INTERACTION_TYPE.STARGAZER 54 | db_stargazers = models.RepositoryUser.from_orm(stargazer) 55 | session.add(db_stargazers) 56 | session.commit() 57 | 58 | email_user = session.query(models.EmailUser).where(models.EmailUser.uuid == user_id).first() 59 | 60 | # assign email user to repository 61 | repository.email_user_id = email_user.id 62 | session.commit() 63 | 64 | # send email 65 | msg_creator = MessageCreator(session, repository.id) 66 | message_text, archive_path = msg_creator.create_message() 67 | email_sender.send_message( 68 | subject=f"Repository summary for {repository.name}", 69 | message=message_text, 70 | recipient=email_user, 71 | file_path=archive_path, 72 | ) 73 | msg_creator.clear_temp() 74 | 75 | 76 | @repository_router.post("/", response_model=models.RepositoryResponse) 77 | async def create_repository(*, 78 | session: Session = ActiveSession, 79 | background_tasks: BackgroundTasks, 80 | user_id: uuid.UUID, 81 | repository: models.RepositoryCreate, 82 | forks: List[models.RepositoryUserCreate], 83 | stargazers: List[models.RepositoryUserCreate]): 84 | # create repository 85 | db_repository = models.Repository.from_orm(repository) 86 | session.add(db_repository) 87 | session.commit() 88 | session.refresh(db_repository) 89 | 90 | background_tasks.add_task(process_repository, forks, stargazers, user_id, db_repository, session) 91 | 92 | return db_repository 93 | 94 | 95 | login_router = APIRouter() 96 | 97 | 98 | @login_router.post("/email_user/", response_model=models.EmailUserResponse) 99 | async def create_email_user(session: Session = ActiveSession): 100 | # create email user 101 | email_user = models.EmailUser() 102 | email_user.uuid = uuid.uuid4() 103 | session.add(email_user) 104 | session.commit() 105 | 106 | return email_user 107 | 108 | 109 | @login_router.get("/email_user/{email_user_uuid}", response_model=models.EmailUserResponse) 110 | async def get_email_user(email_user_uuid, session: Session = ActiveSession): 111 | # get email user 112 | email_user = session.query(models.EmailUser).where(models.EmailUser.uuid == email_user_uuid).first() 113 | return email_user 114 | 115 | 116 | @login_router.get("/{email_user_uuid}", response_class=HTMLResponse) 117 | def login(request: Request, email_user_uuid): 118 | # google login page 119 | return templates.TemplateResponse("login.html", { 120 | "request": request, 121 | "data_client_id": settings.data_client_id, 122 | "email_user_uuid": email_user_uuid, 123 | "rollbar_access_token": os.getenv("ROLLBAR_CLIENT_TOKEN"), 124 | }) 125 | 126 | 127 | 128 | @login_router.post("/verify/{email_user_uuid}", response_class=HTMLResponse) 129 | async def login(email_user_uuid, request: Request, g_csrf_token: str = Form(), credential: str = Form(), session: Session = ActiveSession,): 130 | # verify google login 131 | csrf_token_cookie = request.cookies.get('g_csrf_token') 132 | if not csrf_token_cookie: 133 | raise HTTPException(status_code=400, detail='No CSRF token in Cookie.') 134 | if not g_csrf_token: 135 | raise HTTPException(status_code=400, detail='No CSRF token in post body.') 136 | if csrf_token_cookie != g_csrf_token: 137 | raise HTTPException(status_code=400, detail='Failed to verify double submit cookie.') 138 | 139 | id_info = id_token.verify_oauth2_token(credential, requests.Request(), settings.data_client_id) 140 | 141 | email_user = session.query(models.EmailUser).where(models.EmailUser.uuid == email_user_uuid).first() 142 | email_user.email = id_info['email'] 143 | email_user.name = id_info['name'] 144 | session.commit() 145 | return templates.TemplateResponse("redirect.html", {"request": request, "email": id_info['email']}) 146 | -------------------------------------------------------------------------------- /server/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import uuid as uuid_pkg 3 | from typing import Optional 4 | from pydantic import BaseModel, Field 5 | from sqlmodel import Field, SQLModel 6 | from enum import Enum 7 | import json 8 | class EnumValue(Enum): 9 | """ 10 | Generic enumeration which will return a value and not an enumeration object 11 | """ 12 | def __str__(self): 13 | return '%s' % self.value 14 | 15 | 16 | class INTERACTION_TYPE(int, EnumValue): 17 | FORK = 1 18 | STARGAZER = 2 19 | 20 | class Repository(SQLModel, table=True): 21 | __tablename__ = "repository" 22 | id: Optional[int] = Field(nullable=False, primary_key=True) 23 | name: str = Field(index=True) 24 | settings: Optional[str] 25 | created: datetime = datetime.now() 26 | stargazers_count: int 27 | forks_count: int 28 | email_user_id: Optional[int] = Field(foreign_key="email_user.id") 29 | contributors_count: Optional[int] 30 | issues_count: Optional[int] 31 | pull_requests_count: Optional[int] 32 | pull_requests_merged_ltm: Optional[int] 33 | total_rating: Optional[int] 34 | watchers_count: Optional[int] 35 | health: Optional[int] 36 | issues_opened_ltm: Optional[int] 37 | issues_history: Optional[str] 38 | stars_history: Optional[str] 39 | last_month_stars: Optional[int] 40 | 41 | 42 | class RepositoryResponse(BaseModel): 43 | """Repository serializer exposed on the API""" 44 | id: int 45 | name: str 46 | settings: Optional[str] 47 | stargazers_count: int 48 | forks_count: int 49 | contributors_count: Optional[int] 50 | issues_count: Optional[int] 51 | pull_requests_count: Optional[int] 52 | pull_requests_merged_ltm: Optional[int] 53 | total_rating: Optional[int] 54 | watchers_count: Optional[int] 55 | health: Optional[int] 56 | issues_opened_ltm: Optional[int] 57 | last_month_stars: Optional[int] 58 | 59 | def __init__(self, *args, **kwargs): 60 | settings = kwargs.pop("settings", None) 61 | if isinstance(settings, str): 62 | kwargs["settings"] = json.loads(settings) 63 | super().__init__(*args, **kwargs) 64 | 65 | class Config: 66 | orm_mode = True 67 | 68 | 69 | class RepositoryCreate(BaseModel): 70 | """Repository serializer for creating model""" 71 | name: str 72 | settings: Optional[str] 73 | stargazers_count: int 74 | forks_count: int 75 | contributors_count: Optional[int] 76 | issues_count: Optional[int] 77 | pull_requests_count: Optional[int] 78 | pull_requests_merged_ltm: Optional[int] 79 | total_rating: Optional[int] 80 | watchers_count: Optional[int] 81 | health: Optional[int] 82 | issues_opened_ltm: Optional[int] 83 | issues_history: Optional[str] 84 | stars_history: Optional[str] 85 | last_month_stars: Optional[int] 86 | 87 | def __init__(self, *args, **kwargs): 88 | settings = kwargs.pop("settings", None) 89 | if isinstance(settings, dict): 90 | kwargs["settings"] = json.dumps(settings) 91 | 92 | issues = kwargs.pop("issues", None) 93 | if issues: 94 | kwargs["issues_history"] = json.dumps(issues["chartData"]) 95 | kwargs["health"] = issues["health"] 96 | kwargs["issues_opened_ltm"] = issues["openedLTM"] 97 | 98 | stars_history = kwargs.pop("stars_history", None) 99 | if isinstance(stars_history, dict): 100 | kwargs["stars_history"] = json.dumps(stars_history) 101 | 102 | kwargs["pull_requests_merged_ltm"] = kwargs["pull_requests_merged_LTM"] 103 | 104 | super().__init__(*args, **kwargs) 105 | 106 | 107 | class RepositoryUser(SQLModel, table=True): 108 | __tablename__ = "repository_user" 109 | id: Optional[int] = Field(nullable=False, primary_key=True) 110 | repository_id: int = Field(foreign_key="repository.id") 111 | geo_hash: Optional[str] 112 | country: Optional[str] 113 | lat: Optional[float] 114 | lon: Optional[float] 115 | interaction_type: int 116 | login: Optional[str] 117 | type: Optional[str] 118 | site_admin: Optional[bool] 119 | name: Optional[str] 120 | company: Optional[str] 121 | blog: Optional[str] 122 | location: Optional[str] 123 | email: Optional[str] 124 | hireable: Optional[str] 125 | bio: Optional[str] 126 | twitter_username: Optional[str] 127 | public_repos: Optional[int] 128 | public_gists: Optional[int] 129 | followers: Optional[int] 130 | following: Optional[int] 131 | event_count: Optional[int] 132 | real_user: Optional[bool] 133 | active_user: Optional[bool] 134 | created_at: datetime 135 | updated_at: datetime 136 | 137 | 138 | class RepositoryUserCreate(BaseModel): 139 | geo_hash: Optional[str] 140 | country: Optional[str] 141 | lat: Optional[float] 142 | lon: Optional[float] 143 | repository_id: Optional[int] 144 | interaction_type: Optional[int] 145 | login: Optional[str] 146 | type: Optional[str] 147 | site_admin: Optional[bool] 148 | name: Optional[str] 149 | company: Optional[str] 150 | blog: Optional[str] 151 | location: Optional[str] 152 | email: Optional[str] 153 | hireable: Optional[str] 154 | bio: Optional[str] 155 | twitter_username: Optional[str] 156 | public_repos: Optional[int] 157 | public_gists: Optional[int] 158 | followers: Optional[int] 159 | following: Optional[int] 160 | event_count: Optional[int] 161 | real_user: Optional[bool] 162 | active_user: Optional[bool] 163 | created_at: datetime 164 | updated_at: datetime 165 | 166 | 167 | class EmailUser(SQLModel, table=True): 168 | __tablename__ = "email_user" 169 | """ 170 | client side initializes email_user. After object is created it is updated with the email using 171 | google authentication. This allow for the authentication to happen outside the extension, and yet to allow the extension 172 | to find the email. 173 | """ 174 | 175 | id: Optional[int] = Field(nullable=False, primary_key=True) 176 | uuid: uuid_pkg.UUID = Field(index=True) 177 | name: Optional[str] 178 | email: Optional[str] 179 | created: datetime = datetime.now() 180 | 181 | 182 | class EmailUserResponse(BaseModel): 183 | """ 184 | Email user serializer 185 | """ 186 | uuid: uuid_pkg.UUID 187 | name: Optional[str] 188 | email: Optional[str] 189 | 190 | class Config: 191 | orm_mode = True -------------------------------------------------------------------------------- /chrome-extension/src/view/options.vue: -------------------------------------------------------------------------------- 1 | 130 | 131 | 196 | 197 | 210 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![repoInspector developed by the Hetz team](https://uploads-ssl.webflow.com/6315e930d2cfb6827aea22b2/637a1c442913f973713f6c76_repoInspector%20header.gif) 2 | 3 | # repoInspector 4 | 5 | repoInspector is a Github repository inspector built for anyone to gather useful data on open source repositories. Using the open Github API, we package and present available data about any repository, including: 6 | 7 | - number of profiles 8 | - number of active users 9 | - number of all users 10 | - Geographical breakdown (countries) 11 | - Organizations to which a repo’s users belong 12 | - Plus, other data as available 13 | 14 | This one’s designed to be simple to use as a Chrome extension, for startup founders, devs, investors, and really - anyone! 15 | 16 | ## Let's get started! 17 | 18 | 1. Download the Chrome Extension [here](https://chrome.google.com/webstore/detail/repo-inspector/hogkmljfjdgibeoepenpfggaiipngknb). 19 | 2. Log into your [Github](https://github.com/) account (if you don't have one, create one). 20 | 3. Create a token by clicking here or by going to Settings > Developer Settings > Personal Access Token -> Classic 21 | 4. Press 'Generate new token' -> Classic -> and create a token, checking off the following permissions: 22 | 23 | ``` 24 | repo:status Access commit status 25 | repo_deployment Access deployment status 26 | public_repo Access public repositories 27 | repo:invite Access repository invitations 28 | read:packages Download packages from GitHub Package Registry 29 | read:org Read org and team membership, read org projects 30 | read:public_key Read user public keys 31 | read:repo_hook Read repository hooks 32 | read:user Read ALL user profile data 33 | user:email Access user email addresses (read-only) 34 | read:discussion Read team discussions 35 | read:enterprise Read enterprise profile data 36 | read:project Read access of projects 37 | ``` 38 | 39 | > :warning: Set the token’s expiration to whatever you’re comfortable with. We’ll never ask you for your token, and it will always be stored on your computer. 40 | 41 | 5. Open the Chrome extension and paste the token into the token field. 42 | 6. After saving the token, you will be asked to log in with Gmail so we can email you repo data as you request it. 43 | 44 | ## Using repoInspector 45 | 46 | 47 | 48 | You’re ready to start inspecting repositories! Here’s how that works: 49 | 50 | 1. In your browser, go to the Github page for a repository you’re curious about, open the Chrome extension and click Inspect. 51 | Before you inspect, you have a few options for the data. Toggle between receiving Only Stars (default), Only Forks, Stars and Forks, or Sampling. 52 | 2. Once you click Inspect, you’ll see a progress window. 53 | 54 | Note - it may take a few seconds to start showing progress, and the larger the amount of Stars or Forks, the longer it will take to start and progress. 55 | 56 | Note - up to 40k Stars and 40k Forks can be pulled for any one inspection. 57 | 58 | 3. Your result will arrive in your inbox once the progress bar hits 100%. 59 | If you don't see the email right away, check your spam folder. The report will send from repoinspector@hetzventures.org. 60 | 61 | ## About us 62 | 63 | [repoInspector](https://chrome.google.com/webstore/detail/repo-inspector/hogkmljfjdgibeoepenpfggaiipngknb) was originally designed and started by the team at [Hetz Ventures](https://www.hetz.vc/). We were looking for a simple way to access useful data on repository user activity around Github projects for industry insights, due diligence, comparative analysis, etc. 64 | 65 | This Chrome extension is useful for other investors, startup founders and really anyone looking to better understand user behavior and their markets. 66 | 67 | We welcome contributors to this project! Here’s how you can get set up: 68 | 69 | ## Project setup 70 | 71 | There is both a client and a server in this repository. You can decide to only work on the client side (i.e. the chrome extension) or on both. 72 | To set up the client side, clone the repo and: 73 | 74 | ``` 75 | cd chrome-extension 76 | yarn install 77 | ``` 78 | 79 | ### Graphql schema generation 80 | 81 | ``` 82 | yarn generate-ts-gql (This command must be executed each time after creating a new query to generate new types) 83 | ``` 84 | 85 | ### Compiles and hot-reloads for development 86 | 87 | ``` 88 | yarn build-watch 89 | ``` 90 | 91 | This will create a `dist` folder. Go to [chrome://extensions](chrome://extensions) and click 'Load unpacked', after which navigate to the `dist` folder and click open, the extension should load and will reload with every save to the codebase. 92 | 93 | ### Compiles and minifies for production 94 | 95 | ``` 96 | yarn run build 97 | ``` 98 | 99 | ### Lints and fixes files 100 | 101 | ``` 102 | yarn run lint 103 | ``` 104 | 105 | ### Customize configuration 106 | 107 | See [Configuration Reference](https://cli.vuejs.org/config/). 108 | 109 | ### Server setup 110 | 111 | ``` 112 | cd server 113 | pip install -r requirements.txt 114 | ``` 115 | 116 | Create a file called `.secrets.toml` in the `server` directory and fill it in with the following (fill in the missing variables): 117 | 118 | ``` 119 | [development] 120 | dynaconf_merge = true 121 | 122 | [development.db] 123 | sql_url = '' 124 | 125 | [development.google] 126 | data_client_id = "" 127 | 128 | [development.email] 129 | username = '' 130 | password = '' 131 | smtp_server = '' 132 | port = '' 133 | 134 | [development.security] 135 | # openssl rand -hex 32 136 | SECRET_KEY = "" 137 | ``` 138 | 139 | ### Run server 140 | 141 | ``` 142 | uvicorn main:app 143 | ``` 144 | 145 | ### Production Setup 146 | 147 | The server is set up to run on Heroku, to push to heroku, add a new heroku remote and run from the base folder: 148 | 149 | ``` 150 | git subtree push --prefix server heroku main 151 | ``` 152 | 153 | The environment variables convention on the server `API_DB__` so for sql_url it would be `API_DB__sql_url`. 154 | 155 | ## Classifications 156 | 157 | Some data is classified by our business logic and is not fetched directly from Github. The following are data points we have defined: 158 | 159 | - Real user - user who has had more than three "events" (commit, PR, etc.) or user who has more than three followers. 160 | - Active user - user who has an event in the past year. 161 | - Country - while users can input their location in their profile, this is free-text which make it difficult to aggregate. To solve this problem, we use nominatim.openstreetmap.org location api to fetch the country of every location free-text and extract its country. 162 | -------------------------------------------------------------------------------- /chrome-extension/src/view/components/TotalRatingWeightsSettings.vue: -------------------------------------------------------------------------------- 1 | 114 | 115 | 183 | 184 | 219 | -------------------------------------------------------------------------------- /server/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 |
21 |
Login to repoInspector
22 |
23 |
27 |
28 | 35 |
36 |
37 | Your email will only be used to send you reports 38 |
39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node,python,flask,vuejs,vue,yarn 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,python,flask,vuejs,vue,yarn 3 | 4 | ### Flask ### 5 | instance/* 6 | !instance/.gitignore 7 | .webassets-cache 8 | .env 9 | .devcontainer/* 10 | 11 | ### Flask.Python Stack ### 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | chrome-extension/src/js/env.js 18 | 19 | # C extensions 20 | *.so 21 | server/temp/* 22 | !server/temp/__init__.py 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | share/python-wheels/ 38 | *.egg-info/ 39 | .installed.cfg 40 | *.egg 41 | MANIFEST 42 | 43 | # PyInstaller 44 | # Usually these files are written by a python script from a template 45 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 46 | *.manifest 47 | *.spec 48 | 49 | # Installer logs 50 | pip-log.txt 51 | pip-delete-this-directory.txt 52 | 53 | # Unit test / coverage reports 54 | htmlcov/ 55 | .tox/ 56 | .nox/ 57 | .coverage 58 | .coverage.* 59 | .cache 60 | nosetests.xml 61 | coverage.xml 62 | *.cover 63 | *.py,cover 64 | .hypothesis/ 65 | .pytest_cache/ 66 | cover/ 67 | 68 | # Translations 69 | *.mo 70 | *.pot 71 | 72 | # Django stuff: 73 | *.log 74 | local_settings.py 75 | db.sqlite3 76 | db.sqlite3-journal 77 | 78 | # Flask stuff: 79 | instance/ 80 | 81 | # Scrapy stuff: 82 | .scrapy 83 | 84 | # Sphinx documentation 85 | docs/_build/ 86 | 87 | # PyBuilder 88 | .pybuilder/ 89 | target/ 90 | 91 | # Jupyter Notebook 92 | .ipynb_checkpoints 93 | 94 | # IPython 95 | profile_default/ 96 | ipython_config.py 97 | 98 | # pyenv 99 | # For a library or package, you might want to ignore these files since the code is 100 | # intended to run in multiple environments; otherwise, check them in: 101 | # .python-version 102 | 103 | # pipenv 104 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 105 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 106 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 107 | # install all needed dependencies. 108 | #Pipfile.lock 109 | 110 | # poetry 111 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 112 | # This is especially recommended for binary packages to ensure reproducibility, and is more 113 | # commonly ignored for libraries. 114 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 115 | #poetry.lock 116 | 117 | # pdm 118 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 119 | #pdm.lock 120 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 121 | # in version control. 122 | # https://pdm.fming.dev/#use-with-ide 123 | .pdm.toml 124 | 125 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 126 | __pypackages__/ 127 | 128 | # Celery stuff 129 | celerybeat-schedule 130 | celerybeat.pid 131 | 132 | # SageMath parsed files 133 | *.sage.py 134 | 135 | # Environments 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | .spyproject 146 | 147 | # Rope project settings 148 | .ropeproject 149 | 150 | # mkdocs documentation 151 | /site 152 | 153 | # mypy 154 | .mypy_cache/ 155 | .dmypy.json 156 | dmypy.json 157 | 158 | # Pyre type checker 159 | .pyre/ 160 | 161 | # pytype static type analyzer 162 | .pytype/ 163 | 164 | # Cython debug symbols 165 | cython_debug/ 166 | 167 | # PyCharm 168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 170 | # and can be added to the global gitignore or merged into this file. For a more nuclear 171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 172 | #.idea/ 173 | 174 | ### Node ### 175 | # Logs 176 | logs 177 | npm-debug.log* 178 | yarn-debug.log* 179 | yarn-error.log* 180 | lerna-debug.log* 181 | .pnpm-debug.log* 182 | 183 | # Diagnostic reports (https://nodejs.org/api/report.html) 184 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 185 | 186 | # Runtime data 187 | pids 188 | *.pid 189 | *.seed 190 | *.pid.lock 191 | 192 | # Directory for instrumented libs generated by jscoverage/JSCover 193 | lib-cov 194 | 195 | # Coverage directory used by tools like istanbul 196 | coverage 197 | *.lcov 198 | 199 | # nyc test coverage 200 | .nyc_output 201 | 202 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 203 | .grunt 204 | 205 | # Bower dependency directory (https://bower.io/) 206 | bower_components 207 | 208 | # node-waf configuration 209 | .lock-wscript 210 | 211 | # Compiled binary addons (https://nodejs.org/api/addons.html) 212 | build/Release 213 | 214 | # Dependency directories 215 | node_modules/ 216 | jspm_packages/ 217 | 218 | # Snowpack dependency directory (https://snowpack.dev/) 219 | web_modules/ 220 | 221 | # TypeScript cache 222 | *.tsbuildinfo 223 | 224 | # Optional npm cache directory 225 | .npm 226 | 227 | # Optional eslint cache 228 | .eslintcache 229 | 230 | # Optional stylelint cache 231 | .stylelintcache 232 | 233 | # Microbundle cache 234 | .rpt2_cache/ 235 | .rts2_cache_cjs/ 236 | .rts2_cache_es/ 237 | .rts2_cache_umd/ 238 | 239 | # Optional REPL history 240 | .node_repl_history 241 | 242 | # Output of 'npm pack' 243 | *.tgz 244 | 245 | # Yarn Integrity file 246 | .yarn-integrity 247 | 248 | # dotenv environment variable files 249 | .env.development.local 250 | .env.test.local 251 | .env.production.local 252 | .env.local 253 | 254 | # parcel-bundler cache (https://parceljs.org/) 255 | .parcel-cache 256 | 257 | # Next.js build output 258 | .next 259 | out 260 | 261 | # Nuxt.js build / generate output 262 | .nuxt 263 | dist 264 | 265 | # Gatsby files 266 | .cache/ 267 | # Comment in the public line in if your project uses Gatsby and not Next.js 268 | # https://nextjs.org/blog/next-9-1#public-directory-support 269 | # public 270 | 271 | # vuepress build output 272 | .vuepress/dist 273 | 274 | # vuepress v2.x temp and cache directory 275 | .temp 276 | 277 | # Docusaurus cache and generated files 278 | .docusaurus 279 | 280 | # Serverless directories 281 | .serverless/ 282 | 283 | # FuseBox cache 284 | .fusebox/ 285 | 286 | # DynamoDB Local files 287 | .dynamodb/ 288 | 289 | # config 290 | .secrets.toml 291 | # TernJS port file 292 | .tern-port 293 | 294 | # Stores VSCode versions used for testing VSCode extensions 295 | .vscode-test 296 | .vscode/settings.json 297 | 298 | # yarn v2 299 | .yarn/cache 300 | .yarn/unplugged 301 | .yarn/build-state.yml 302 | .yarn/install-state.gz 303 | .pnp.* 304 | 305 | ### Node Patch ### 306 | # Serverless Webpack directories 307 | .webpack/ 308 | 309 | # Optional stylelint cache 310 | 311 | # SvelteKit build / generate output 312 | .svelte-kit 313 | 314 | ### Python ### 315 | # Byte-compiled / optimized / DLL files 316 | 317 | # C extensions 318 | 319 | # Distribution / packaging 320 | 321 | # PyInstaller 322 | # Usually these files are written by a python script from a template 323 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 324 | 325 | # Installer logs 326 | 327 | # Unit test / coverage reports 328 | 329 | # Translations 330 | 331 | # Django stuff: 332 | 333 | # Flask stuff: 334 | 335 | # Scrapy stuff: 336 | 337 | # Sphinx documentation 338 | 339 | # PyBuilder 340 | 341 | # Jupyter Notebook 342 | 343 | # IPython 344 | 345 | # pyenv 346 | # For a library or package, you might want to ignore these files since the code is 347 | # intended to run in multiple environments; otherwise, check them in: 348 | # .python-version 349 | 350 | # pipenv 351 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 352 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 353 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 354 | # install all needed dependencies. 355 | 356 | # poetry 357 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 358 | # This is especially recommended for binary packages to ensure reproducibility, and is more 359 | # commonly ignored for libraries. 360 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 361 | 362 | # pdm 363 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 364 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 365 | # in version control. 366 | # https://pdm.fming.dev/#use-with-ide 367 | 368 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 369 | 370 | # Celery stuff 371 | 372 | # SageMath parsed files 373 | 374 | # Environments 375 | 376 | # Spyder project settings 377 | 378 | # Rope project settings 379 | 380 | # mkdocs documentation 381 | 382 | # mypy 383 | 384 | # Pyre type checker 385 | 386 | # pytype static type analyzer 387 | 388 | # Cython debug symbols 389 | 390 | # PyCharm 391 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 392 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 393 | # and can be added to the global gitignore or merged into this file. For a more nuclear 394 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 395 | 396 | ### Vue ### 397 | # gitignore template for Vue.js projects 398 | # 399 | # Recommended template: Node.gitignore 400 | 401 | # TODO: where does this rule come from? 402 | docs/_book 403 | 404 | # TODO: where does this rule come from? 405 | test/ 406 | 407 | # mac 408 | *.DS_Store 409 | 410 | ### Vuejs ### 411 | # Recommended template: Node.gitignore 412 | 413 | npm-debug.log 414 | yarn-error.log 415 | 416 | ### yarn ### 417 | # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 418 | 419 | .yarn/* 420 | !.yarn/releases 421 | !.yarn/patches 422 | !.yarn/plugins 423 | !.yarn/sdks 424 | !.yarn/versions 425 | 426 | # if you are NOT using Zero-installs, then: 427 | # comment the following lines 428 | !.yarn/cache 429 | 430 | # and uncomment the following lines 431 | # .pnp.* 432 | 433 | # End of https://www.toptal.com/developers/gitignore/api/node,python,flask,vuejs,vue,yarn 434 | 435 | 436 | graphql.schema.* -------------------------------------------------------------------------------- /chrome-extension/src/view/components/HistoryCard.vue: -------------------------------------------------------------------------------- 1 | 126 | 127 | 335 | 336 | 462 | -------------------------------------------------------------------------------- /chrome-extension/src/features/repoInspector.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/core'; 2 | import { 3 | getForkersQuery, 4 | getStargazersQuery, 5 | issuesQuery, 6 | pullRequestsQuery, 7 | starHistoryQuery, 8 | } from '@/features/gql/queries'; 9 | import type { 10 | GetForkersQueryQuery, 11 | GetStargazersQueryQuery, 12 | IssuesQueryQuery, 13 | PullRequestsQueryQuery, 14 | StarHistoryQueryQuery, 15 | } from '@/features/gql/graphql.schema'; 16 | import { 17 | getIssuesStatistic, 18 | getOctokitRepoData, 19 | getPullRequestStatistic, 20 | groupStarsHistoryByMonth, 21 | initToken, 22 | serializeUser, 23 | } from './utils'; 24 | import { initOctokit } from './octokit'; 25 | 26 | import { STAGE } from './store/models'; 27 | import { downloaderStore } from './store/downloader'; 28 | import { inspectDataStore } from './store/inspectData'; 29 | import { NOTIFICATION_TYPES, notificationStore } from './store/notification'; 30 | import { api } from './api'; 31 | import { auth } from './authentication'; 32 | import { historyStore } from './store/history'; 33 | import { MINIMUM_REQUEST_LIMIT_AMOUNT, USERS_QUERY_LIMIT } from './constants'; 34 | import { calculateTotalRating } from './utils/calculateTotalRating'; 35 | 36 | let octokit: Octokit; 37 | 38 | initToken().then((token) => { 39 | octokit = initOctokit(token); 40 | }); 41 | 42 | class RepoInspector { 43 | // used for prevent double recording current inspection state to local store when query limit reached 44 | alreadyPaused: boolean; 45 | 46 | constructor() { 47 | this.alreadyPaused = false; 48 | } 49 | 50 | async inspectAssets(downloader: Downloader) { 51 | const { url, stage, lastStage } = downloader; 52 | 53 | // If there are no inspections running, do nothing. 54 | if (stage < STAGE.INITIATED || stage > STAGE.GETTING_USERS) return; 55 | 56 | const { owner, name } = getOctokitRepoData(url); 57 | 58 | // if we can't receive owner or name of repository, do nothing 59 | if (!owner || !name) { 60 | await this._stopByError('Please check repository URL'); 61 | 62 | return; 63 | } 64 | 65 | this.alreadyPaused = false; 66 | // check if it is new inspection or we continue previous one which was paused 67 | let isUnpaused = stage === STAGE.UNPAUSED; 68 | const isInitiated = stage === STAGE.INITIATED; 69 | 70 | if (isInitiated) inspectDataStore.refresh(); 71 | 72 | if (lastStage === 'additional' || isInitiated) { 73 | await downloaderStore.setStage(STAGE.GETTING_ADDITIONAL_STATISTIC); 74 | isUnpaused = false; 75 | 76 | // Promise.all allows us to make paralleled requests for faster responses 77 | await Promise.all([ 78 | await this.getIssues(owner, name), 79 | await this.getPullRequests(owner, name), 80 | await this.getStarHistory(owner, name), 81 | ]); 82 | } 83 | 84 | await downloaderStore.setStage(STAGE.GETTING_USERS); 85 | 86 | // if it is new inspection or if it was paused on stage getting stargazers 87 | if (!isUnpaused || lastStage === 'stargazers') { 88 | if (downloader.settings?.stars) { 89 | await this.getUsers( 90 | owner, 91 | name, 92 | 'stargazers', 93 | USERS_QUERY_LIMIT, 94 | downloader.stargazers_users, 95 | downloader.settings.location, 96 | downloader.stargazers_users_data ?? [], 97 | downloader.cursor, 98 | ); 99 | } 100 | 101 | if (downloader.settings?.forks) { 102 | await this.getUsers( 103 | owner, 104 | name, 105 | 'forks', 106 | USERS_QUERY_LIMIT, 107 | downloader.forks_users, 108 | downloader.settings.location, 109 | ); 110 | } 111 | } 112 | 113 | // only if it was paused on stage getting forkers 114 | if (isUnpaused && lastStage === 'forks' && downloader.settings?.forks) { 115 | await this.getUsers( 116 | owner, 117 | name, 118 | 'forks', 119 | USERS_QUERY_LIMIT, 120 | downloader.forks_users, 121 | downloader.settings.location, 122 | downloader.forks_users_data, 123 | downloader.cursor, 124 | ); 125 | } 126 | 127 | if (!this.alreadyPaused) { 128 | this._finishInspection(); 129 | } 130 | } 131 | 132 | async getUsers( 133 | owner: string, 134 | name: string, 135 | type: 'stargazers' | 'forks', 136 | limit: number, 137 | max: number, 138 | isExtendLocation: boolean, 139 | prev: DBUser[] = [], 140 | cursor: null | string = null, 141 | ): Promise<{ success: boolean }> { 142 | const downloader = await downloaderStore.get(); 143 | if (!downloader.active) return { success: false }; 144 | 145 | let items: any[] = [...prev]; 146 | 147 | const query = type === 'stargazers' ? getStargazersQuery : getForkersQuery; 148 | 149 | const inspectDataPropertyName = 150 | type === 'stargazers' ? 'stargaze_users' : 'fork_users'; 151 | 152 | try { 153 | const resp = await octokit.graphql< 154 | GetForkersQueryQuery & GetStargazersQueryQuery 155 | >(query, { 156 | owner, 157 | name, 158 | cursor, 159 | limit, 160 | }); 161 | 162 | const hasNextPage = resp?.repository?.[type].pageInfo.hasNextPage; 163 | const endCursor = resp?.repository?.[type].pageInfo.endCursor; 164 | const currentRequestItems = resp?.repository?.[type].edges ?? []; 165 | const requestRemaining = resp?.rateLimit?.remaining; 166 | 167 | // remove artifacts of graphQL response and normalize data 168 | const normalizedCurrentRequestItems = await Promise.all( 169 | currentRequestItems.map(async (item) => { 170 | const mappedItem: GithubUser = 171 | type === 'stargazers' 172 | ? { ...(item as StargazerUser)?.node } 173 | : { ...(item as ForkUser)?.node?.owner }; 174 | 175 | if (!mappedItem.login) return {}; 176 | 177 | const serializedItem = serializeUser( 178 | mappedItem, 179 | octokit, 180 | isExtendLocation, 181 | ); 182 | 183 | return serializedItem; 184 | }), 185 | ); 186 | items = [...items, ...normalizedCurrentRequestItems]; 187 | 188 | downloaderStore.increaseProgress(); 189 | 190 | // if query limits reached - pause inspection 191 | if ( 192 | requestRemaining && 193 | requestRemaining <= MINIMUM_REQUEST_LIMIT_AMOUNT 194 | ) { 195 | await inspectDataStore.set(inspectDataPropertyName, items as DBUser[]); 196 | this._pauseInspection(type, resp?.rateLimit?.resetAt, endCursor); 197 | 198 | return { success: false }; 199 | } 200 | 201 | // if there are more data than we already receive - request for new portion of data 202 | if (hasNextPage && items.length < max) { 203 | return await this.getUsers( 204 | owner, 205 | name, 206 | type, 207 | USERS_QUERY_LIMIT, 208 | max, 209 | isExtendLocation, 210 | items, 211 | endCursor, 212 | ); 213 | } 214 | } catch (error) { 215 | await this._stopByError(); 216 | } 217 | 218 | // save collected users to global store 219 | inspectDataStore.set(inspectDataPropertyName, items as DBUser[]); 220 | 221 | return { success: true }; 222 | } 223 | 224 | async getStarHistory( 225 | owner: string, 226 | name: string, 227 | prev: StarHistory[] = [], 228 | cursor: null | string = null, 229 | ): Promise<{ success: boolean }> { 230 | const downloader = await downloaderStore.get(); 231 | if (!downloader.active) return { success: false }; 232 | 233 | let items: StarHistory[] = [...prev]; 234 | 235 | try { 236 | const resp = await octokit.graphql( 237 | starHistoryQuery, 238 | { 239 | owner, 240 | name, 241 | cursor, 242 | }, 243 | ); 244 | 245 | const hasNextPage = resp?.repository?.stargazers?.pageInfo.hasNextPage; 246 | const endCursor = resp?.repository?.stargazers?.pageInfo.endCursor; 247 | const responseItems = 248 | resp?.repository?.stargazers?.edges?.filter(Boolean) ?? []; 249 | items = [...items, ...(responseItems as StarHistory[])]; 250 | const requestRemaining = resp?.rateLimit?.remaining; 251 | 252 | // if query limits reached - pause inspection 253 | if ( 254 | requestRemaining && 255 | requestRemaining <= MINIMUM_REQUEST_LIMIT_AMOUNT 256 | ) { 257 | this._pauseInspection('additional', resp?.rateLimit?.resetAt); 258 | 259 | return { success: false }; 260 | } 261 | 262 | downloaderStore.increaseProgress(); 263 | 264 | // if there are more data than we already receive - request for new portion of data 265 | if (hasNextPage) { 266 | return await this.getStarHistory(owner, name, items, endCursor); 267 | } 268 | } catch (error) { 269 | await this._stopByError(); 270 | } 271 | 272 | const { stars_history, lastMonthStars } = groupStarsHistoryByMonth(items); 273 | 274 | // save collected users to global store 275 | inspectDataStore.set('stars_history', stars_history); 276 | inspectDataStore.set('lastMonthStars', lastMonthStars); 277 | 278 | return { success: true }; 279 | } 280 | 281 | async getIssues( 282 | owner: string, 283 | name: string, 284 | prev: Issue[] = [], 285 | cursor: null | string = null, 286 | ): Promise<{ success: boolean }> { 287 | const downloader = await downloaderStore.get(); 288 | if (!downloader.active) return { success: false }; 289 | 290 | let items: Issue[] = [...prev]; 291 | 292 | try { 293 | const resp = await octokit.graphql(issuesQuery, { 294 | owner, 295 | name, 296 | cursor, 297 | }); 298 | 299 | const hasNextPage = resp?.repository?.issues?.pageInfo.hasNextPage; 300 | const endCursor = resp?.repository?.issues?.pageInfo.endCursor; 301 | items = [ 302 | ...items, 303 | ...((resp?.repository?.issues?.edges?.filter(Boolean) as Issue[]) ?? 304 | []), 305 | ]; 306 | const requestRemaining = resp?.rateLimit?.remaining; 307 | 308 | // if query limits reached - pause inspection 309 | if ( 310 | requestRemaining && 311 | requestRemaining <= MINIMUM_REQUEST_LIMIT_AMOUNT 312 | ) { 313 | this._pauseInspection('additional', resp?.rateLimit?.resetAt); 314 | 315 | return { success: false }; 316 | } 317 | 318 | downloaderStore.increaseProgress(); 319 | 320 | // if there are more data than we already receive - request for new portion of data 321 | if (hasNextPage) { 322 | return await this.getIssues(owner, name, items, endCursor); 323 | } 324 | } catch (error) { 325 | await this._stopByError(); 326 | } 327 | 328 | const issues = getIssuesStatistic(items); 329 | 330 | // save collected users to global store 331 | inspectDataStore.set('issues', issues); 332 | 333 | return { success: true }; 334 | } 335 | 336 | async getPullRequests( 337 | owner: string, 338 | name: string, 339 | prev: PullRequest[] = [], 340 | cursor: null | string = null, 341 | ): Promise<{ success: boolean }> { 342 | const downloader = await downloaderStore.get(); 343 | if (!downloader.active) return { success: false }; 344 | 345 | let items: PullRequest[] = [...prev]; 346 | 347 | try { 348 | const resp = await octokit.graphql( 349 | pullRequestsQuery, 350 | { 351 | owner, 352 | name, 353 | cursor, 354 | }, 355 | ); 356 | 357 | const hasNextPage = resp?.repository?.pullRequests?.pageInfo.hasNextPage; 358 | const endCursor = resp?.repository?.pullRequests?.pageInfo.endCursor; 359 | items = [ 360 | ...items, 361 | ...((resp?.repository?.pullRequests?.edges?.filter( 362 | Boolean, 363 | ) as PullRequest[]) ?? []), 364 | ]; 365 | const requestRemaining = resp?.rateLimit?.remaining; 366 | 367 | // if query limits reached - pause inspection 368 | if ( 369 | requestRemaining && 370 | requestRemaining <= MINIMUM_REQUEST_LIMIT_AMOUNT 371 | ) { 372 | this._pauseInspection('additional', resp?.rateLimit?.resetAt); 373 | 374 | return { success: false }; 375 | } 376 | 377 | downloaderStore.increaseProgress(); 378 | 379 | // if there are more data than we already receive - request for new portion of data 380 | if (hasNextPage) { 381 | return await this.getPullRequests(owner, name, items, endCursor); 382 | } 383 | } catch (error) { 384 | await this._stopByError(); 385 | } 386 | 387 | const prsMergedLTM = getPullRequestStatistic(items); 388 | 389 | // save collected users to global store 390 | inspectDataStore.set('pull_requests_merged_LTM', prsMergedLTM); 391 | 392 | return { success: true }; 393 | } 394 | 395 | async _stopByError(message?: string) { 396 | const errorMessage = 397 | message || 'Something went wrong. Please start from begin'; 398 | 399 | notificationStore.set({ 400 | type: NOTIFICATION_TYPES.ERROR, 401 | message: errorMessage, 402 | }); 403 | 404 | inspectDataStore.refresh(); 405 | await downloaderStore.reset(); 406 | } 407 | 408 | async _finishInspection() { 409 | // on finish inspection we send the data to the server for packaging and emailing it. 410 | const downloader = await downloaderStore.get(); 411 | 412 | const inspectData = inspectDataStore.inspectDataDb; 413 | const forks = inspectData.fork_users.filter(({ login }) => login); 414 | const stargazers = inspectData.stargaze_users.filter(({ login }) => login); 415 | const postData = { 416 | repository: { 417 | ...downloader, 418 | issues: inspectData.issues, 419 | pull_requests_merged_LTM: inspectData.pull_requests_merged_LTM, 420 | stars_history: inspectData.stars_history, 421 | last_month_stars: inspectData.lastMonthStars, 422 | total_rating: 0, 423 | }, 424 | forks, 425 | stargazers, 426 | }; 427 | 428 | const dataForRating: DataForRating = { 429 | stars: postData.repository.stargazers_count, 430 | contributors: postData.repository.contributors_count, 431 | starsGrowth: 432 | ((postData.repository.lastMonthStars ?? 0) / 433 | postData.repository.stargazers_count) * 434 | 100, 435 | starsActivity: 436 | postData.repository.stargazers_count / 437 | Object.keys(postData.repository?.stars_history ?? []).length, 438 | forksStars: 439 | postData.repository.forks_count / postData.repository.stargazers_count, 440 | issuesOpenedLTM: postData.repository.issues.openedLTM ?? 0, 441 | issuesClosedLTM: postData.repository.issues.closedLTM ?? 0, 442 | pmMergedLTM: postData.repository.pull_requests_merged_LTM ?? 0, 443 | }; 444 | 445 | postData.repository.total_rating = calculateTotalRating( 446 | dataForRating, 447 | downloader.totalRatingWeights, 448 | ); 449 | 450 | try { 451 | const data = await api.post( 452 | `repository/?user_id=${auth.currentUser.uuid}`, 453 | postData, 454 | ); 455 | 456 | downloader.stage = STAGE.DONE; 457 | downloader.id = data.id; 458 | downloader.stars_history = inspectData.stars_history; 459 | downloader.issues_statistic = inspectData.issues; 460 | downloader.prsMergedLTM = inspectData.pull_requests_merged_LTM; 461 | downloader.lastMonthStars = inspectData.lastMonthStars; 462 | downloader.total_rating = postData.repository.total_rating; 463 | 464 | notificationStore.set({ 465 | type: NOTIFICATION_TYPES.SUCCESS, 466 | message: 'Nice! Your repo data has been sent to your email.', 467 | }); 468 | } catch (error) { 469 | alert(error); 470 | downloader.stage = STAGE.ERROR; 471 | } 472 | 473 | // save data to history 474 | await historyStore.set(downloader); 475 | inspectDataStore.refresh(); 476 | await downloaderStore.reset(); 477 | } 478 | 479 | async _pauseInspection( 480 | lastStage: LastStage, 481 | restoreLimitsDate: Date, 482 | cursor?: string | null, 483 | ) { 484 | // for now we use pause if github API request limits are reached 485 | const downloader = await downloaderStore.get(); 486 | 487 | if (!this.alreadyPaused) { 488 | this.alreadyPaused = true; 489 | const inspectData = inspectDataStore.inspectDataDb; 490 | 491 | downloader.stage = STAGE.PAUSE; 492 | downloader.lastStage = lastStage; 493 | downloader.cursor = cursor; 494 | downloader.restoreLimitsDate = restoreLimitsDate; 495 | downloader.stars_history = inspectData.stars_history; 496 | downloader.issues_statistic = inspectData.issues; 497 | downloader.stargazers_users_data = inspectData.stargaze_users; 498 | downloader.forks_users_data = inspectData.fork_users; 499 | downloader.prsMergedLTM = inspectData.pull_requests_merged_LTM; 500 | 501 | // save data to history 502 | await historyStore.set(downloader); 503 | inspectDataStore.refresh(); 504 | await downloaderStore.reset(); 505 | 506 | notificationStore.set({ 507 | type: NOTIFICATION_TYPES.ERROR, 508 | message: `Queries limit reached. Try after ${new Date( 509 | restoreLimitsDate, 510 | ).toLocaleTimeString()}`, 511 | }); 512 | } 513 | } 514 | } 515 | 516 | export const repoInspector = new RepoInspector(); 517 | -------------------------------------------------------------------------------- /chrome-extension/src/view/popup.vue: -------------------------------------------------------------------------------- 1 | 284 | 285 | 565 | 566 | 606 | --------------------------------------------------------------------------------