├── .editorconfig ├── .firebaserc ├── .github ├── FUNDING.yml ├── actions │ └── setup-go │ │ └── action.yml ├── copilot-instructions.md ├── renovate.json └── workflows │ ├── ci.yml │ ├── deploy-production.yml │ ├── deploy-staging.yml │ └── release-please.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .prettierignore ├── .vscode └── extensions.json ├── CHANGELOG.md ├── README.md ├── apps ├── api │ ├── go │ │ ├── apiclient │ │ │ └── google.go │ │ ├── compress │ │ │ ├── compress.go │ │ │ └── compress_test.go │ │ ├── dataurl │ │ │ ├── .snapshots │ │ │ │ └── TestConvert │ │ │ ├── resolver.go │ │ │ └── resolver_test.go │ │ ├── env │ │ │ ├── env.go │ │ │ └── env_test.go │ │ ├── httptrace │ │ │ └── httptrace.go │ │ ├── model │ │ │ ├── contributor.go │ │ │ ├── file.go │ │ │ ├── repository.go │ │ │ └── repository_test.go │ │ ├── project.json │ │ ├── renderer │ │ │ ├── .snapshots │ │ │ │ └── TestRender_Snapshot.svg │ │ │ ├── image.go │ │ │ ├── renderer.go │ │ │ └── renderer_test.go │ │ └── util │ │ │ ├── fns.go │ │ │ └── fns_test.go │ ├── internal │ │ ├── api │ │ │ ├── image │ │ │ │ ├── api.go │ │ │ │ ├── params.go │ │ │ │ └── params_test.go │ │ │ └── routes.go │ │ ├── config │ │ │ ├── config.go │ │ │ ├── config_test.go │ │ │ ├── middleware.go │ │ │ └── testing.go │ │ ├── github │ │ │ ├── api │ │ │ │ └── github.go │ │ │ └── provider.go │ │ ├── logger │ │ │ ├── label.go │ │ │ ├── logger.go │ │ │ ├── middleware.go │ │ │ ├── middleware_test.go │ │ │ └── trace.go │ │ ├── server.go │ │ ├── service │ │ │ ├── contributors │ │ │ │ ├── github.go │ │ │ │ ├── github_test.go │ │ │ │ └── service.go │ │ │ ├── image │ │ │ │ ├── options.go │ │ │ │ ├── service.go │ │ │ │ └── service_test.go │ │ │ ├── internal │ │ │ │ ├── appcache │ │ │ │ │ ├── appcache.go │ │ │ │ │ ├── gcs.go │ │ │ │ │ └── memory.go │ │ │ │ ├── cachekey │ │ │ │ │ ├── keys.go │ │ │ │ │ └── keys_test.go │ │ │ │ └── cacheutil │ │ │ │ │ └── logs.go │ │ │ ├── services.go │ │ │ └── usage │ │ │ │ └── service.go │ │ ├── testing │ │ │ └── .env │ │ └── tracing │ │ │ ├── middleware.go │ │ │ └── tracing.go │ ├── main.go │ └── project.json ├── webapp │ ├── .postcssrc.json │ ├── project.json │ ├── proxy.conf.json │ ├── src │ │ ├── app │ │ │ ├── app-routes.ts │ │ │ ├── app.component.scss │ │ │ ├── app.component.spec.ts │ │ │ ├── app.component.ts │ │ │ ├── app.config.ts │ │ │ ├── components │ │ │ │ └── svg-view │ │ │ │ │ ├── svg-view.component.spec.ts │ │ │ │ │ └── svg-view.component.ts │ │ │ ├── models │ │ │ │ ├── image-params.ts │ │ │ │ ├── index.ts │ │ │ │ ├── repository.spec.ts │ │ │ │ └── repository.ts │ │ │ ├── pages │ │ │ │ └── preview │ │ │ │ │ ├── footer │ │ │ │ │ ├── footer.component.scss │ │ │ │ │ ├── footer.component.spec.ts │ │ │ │ │ └── footer.component.ts │ │ │ │ │ ├── header │ │ │ │ │ ├── header.component.scss │ │ │ │ │ ├── header.component.spec.ts │ │ │ │ │ └── header.component.ts │ │ │ │ │ ├── image-preview-form │ │ │ │ │ ├── image-preview-form.component.scss │ │ │ │ │ ├── image-preview-form.component.spec.ts │ │ │ │ │ └── image-preview-form.component.ts │ │ │ │ │ ├── image-preview-result │ │ │ │ │ ├── image-preview-result.component.scss │ │ │ │ │ ├── image-preview-result.component.spec.ts │ │ │ │ │ └── image-preview-result.component.ts │ │ │ │ │ ├── image-preview │ │ │ │ │ ├── image-preview.component.scss │ │ │ │ │ ├── image-preview.component.spec.ts │ │ │ │ │ └── image-preview.component.ts │ │ │ │ │ ├── image-snippet │ │ │ │ │ ├── image-snippet.component.scss │ │ │ │ │ ├── image-snippet.component.spec.ts │ │ │ │ │ └── image-snippet.component.ts │ │ │ │ │ ├── preview.component.scss │ │ │ │ │ ├── preview.component.spec.ts │ │ │ │ │ ├── preview.component.ts │ │ │ │ │ ├── recent-usage │ │ │ │ │ ├── recent-usage.component.spec.ts │ │ │ │ │ └── recent-usage.component.ts │ │ │ │ │ ├── repository-gallery │ │ │ │ │ ├── repository-gallery.component.spec.ts │ │ │ │ │ └── repository-gallery.component.ts │ │ │ │ │ ├── state.spec.ts │ │ │ │ │ └── state.ts │ │ │ └── shared │ │ │ │ ├── api │ │ │ │ └── contributors-image.ts │ │ │ │ └── featured-repository │ │ │ │ ├── firestore.ts │ │ │ │ ├── index.ts │ │ │ │ └── noop.ts │ │ ├── assets │ │ │ ├── .gitkeep │ │ │ └── images │ │ │ │ ├── github-64px.png │ │ │ │ └── loading.gif │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ ├── environment.staging.ts │ │ │ ├── environment.ts │ │ │ └── firebase-config.ts │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ ├── reset.css │ │ └── styles.scss │ ├── tsconfig.app.json │ └── tsconfig.spec.json └── worker │ ├── .gcloudignore │ ├── package.json │ ├── pnpm-lock.yaml │ ├── project.json │ ├── src │ ├── internal │ │ ├── query.ts │ │ ├── server.ts │ │ └── store.ts │ └── main.ts │ └── tsconfig.app.json ├── eslint.config.js ├── firebase.json ├── firebase ├── firestore.indexes.json └── firestore.rules ├── go.mod ├── go.sum ├── migrations.json ├── nx.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── prettier.config.js ├── tools └── tsconfig.tools.json ├── tsconfig.base.json ├── tsconfig.json └── workspace.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "targets": { 3 | "contributors-img": { 4 | "hosting": { 5 | "production": [ 6 | "contributors-img" 7 | ], 8 | "staging": [ 9 | "contributors-img-staging" 10 | ] 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [lacolaco] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/actions/setup-go/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Go 2 | 3 | runs: 4 | using: 'composite' 5 | steps: 6 | - uses: actions/setup-go@v5 7 | with: 8 | go-version-file: 'go.mod' 9 | check-latest: true 10 | # cache: true 11 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Copilot Instructions 2 | 3 | ## Warning 4 | 5 | - If you find any differences between these instructions and the actual implementation, update these instructions accordingly. 6 | 7 | ## Language 8 | 9 | - Match user's language (Japanese/English/etc.) 10 | - Default to English if unclear 11 | 12 | ## Commands 13 | 14 | - Build: `npx nx build ` 15 | - Test: `npx nx test ` 16 | - Lint: `npx nx lint ` 17 | - Format: `npx nx format:write` 18 | 19 | ## Workspace Structure 20 | 21 | - `/apps/api` - Go API service (contributor image generation) 22 | - `/apps/webapp` - Angular frontend application 23 | - `/apps/worker` - TypeScript background worker 24 | - `/firebase` - Firebase configuration 25 | - `/tools` - Build and utility scripts 26 | 27 | ## Coding Rules 28 | 29 | - Go: Follow standard Go idioms and error handling patterns 30 | - TypeScript: Use strict typing, avoid `any` type 31 | - Angular: Use standalone components and signals API 32 | - Tests: Write unit tests for all new functionality 33 | - Commits: Use conventional commit format 34 | - Documentation: All code comments and inline documentation must be written in English, regardless of the language used in instructions 35 | - Code Comments: 36 | - Keep comments minimal and focused on "why" rather than "what" or "how" 37 | - Only add comments when: 38 | - The code involves complex logic that's not immediately obvious 39 | - The code handles edge cases or uses non-standard approaches 40 | - The implementation deviates from common patterns for a specific reason 41 | - Avoid redundant comments that merely repeat what the code clearly expresses 42 | - Use meaningful variable and function names instead of comments when possible 43 | - For public APIs, ensure proper documentation of parameters and return values 44 | 45 | ## Dependencies Management 46 | 47 | - Node.js: Use pnpm for package management 48 | - Go: Use go modules for dependency management 49 | - Angular: Follow Angular versioning policy 50 | - Update dependencies: Run `pnpm update` to update npm packages 51 | - Go dependencies: Run `go get -u ./...` to update Go modules 52 | - Security updates: Prioritize security-related dependency updates 53 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "schedule:weekly", 6 | "github>lacolaco/renovate-config:automerge-types", 7 | "github>lacolaco/renovate-config:ng-update" 8 | ], 9 | "minimumReleaseAge": "5 days", 10 | "prConcurrentLimit": 5, 11 | "postUpdateOptions": ["gomodTidy", "pnpmDedupe"], 12 | "packageRules": [ 13 | { 14 | "description": "Disable nx package updates except for patch", 15 | "matchPackageNames": ["@nrwl/*", "@nx/*"], 16 | "matchUpdateTypes": ["major", "minor"], 17 | "enabled": false 18 | }, 19 | { 20 | "description": "Automerge devDependencies patch updates", 21 | "matchDepTypes": ["devDependencies"], 22 | "matchUpdateTypes": ["patch", "pin", "digest"], 23 | "automerge": true 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 'CI' 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | jobs: 8 | install-deps: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version-file: '.node-version' 16 | cache: pnpm 17 | - run: pnpm install --frozen-lockfile 18 | - uses: ./.github/actions/setup-go 19 | 20 | build-webapp: 21 | runs-on: ubuntu-latest 22 | needs: [install-deps] 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: pnpm/action-setup@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version-file: '.node-version' 29 | cache: pnpm 30 | - run: pnpm install --frozen-lockfile 31 | - run: pnpm build:all:production 32 | 33 | build-api: 34 | runs-on: ubuntu-latest 35 | needs: [install-deps] 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: ./.github/actions/setup-go 39 | - uses: imjasonh/setup-ko@v0.8 40 | - run: ko build --push=false ./apps/api 41 | env: 42 | KO_DOCKER_REPO: '' 43 | 44 | build-worker: 45 | runs-on: ubuntu-latest 46 | needs: [install-deps] 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: pnpm/action-setup@v4 50 | - uses: actions/setup-node@v4 51 | with: 52 | node-version-file: '.node-version' 53 | cache: pnpm 54 | - run: pnpm install --frozen-lockfile 55 | - run: pnpm build worker 56 | 57 | test: 58 | runs-on: ubuntu-latest 59 | needs: [install-deps] 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: pnpm/action-setup@v4 63 | - uses: actions/setup-node@v4 64 | with: 65 | node-version-file: '.node-version' 66 | cache: pnpm 67 | - run: pnpm install --frozen-lockfile 68 | - uses: ./.github/actions/setup-go 69 | - run: pnpm test:ci 70 | 71 | lint: 72 | runs-on: ubuntu-latest 73 | needs: [install-deps] 74 | steps: 75 | - uses: actions/checkout@v4 76 | - uses: pnpm/action-setup@v4 77 | - uses: actions/setup-node@v4 78 | with: 79 | node-version-file: '.node-version' 80 | cache: pnpm 81 | - run: pnpm install --frozen-lockfile 82 | - uses: ./.github/actions/setup-go 83 | - run: pnpm lint 84 | -------------------------------------------------------------------------------- /.github/workflows/deploy-production.yml: -------------------------------------------------------------------------------- 1 | name: 'Deploy (production)' 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | ref: 7 | type: string 8 | description: 'Git ref to deploy (e.g. main, v1.0.0, c91ee3c, etc.)' 9 | required: true 10 | secrets: 11 | GH_AUTH_TOKEN: 12 | required: true 13 | workflow_dispatch: 14 | inputs: 15 | ref: 16 | type: string 17 | description: 'Git ref to deploy (e.g. main, v1.0.0, c91ee3c, etc.)' 18 | required: true 19 | 20 | permissions: 21 | contents: 'read' 22 | id-token: 'write' 23 | 24 | jobs: 25 | install-deps: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | with: 30 | ref: ${{ inputs.ref }} 31 | - uses: pnpm/action-setup@v4 32 | - uses: actions/setup-node@v4 33 | with: 34 | node-version-file: 'package.json' 35 | cache: pnpm 36 | - run: pnpm install --frozen-lockfile 37 | - uses: ./.github/actions/setup-go 38 | 39 | deploy-api: 40 | environment: production 41 | runs-on: ubuntu-latest 42 | needs: [install-deps] 43 | steps: 44 | - uses: actions/checkout@v4 45 | with: 46 | ref: ${{ inputs.ref }} 47 | - id: 'auth' 48 | uses: 'google-github-actions/auth@v2' 49 | with: 50 | workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} 51 | service_account: ${{ vars.GOOGLE_DEPLOY_SERVICE_ACCOUNT }} 52 | - uses: google-github-actions/setup-gcloud@v2 53 | - uses: ./.github/actions/setup-go 54 | - uses: imjasonh/setup-ko@v0.8 55 | env: 56 | KO_DOCKER_REPO: us-central1-docker.pkg.dev/${{ steps.auth.outputs.project_id }}/cloud-run-builds 57 | - name: Build Docker image of api 58 | id: 'build-api' 59 | run: echo "::set-output name=image::$(ko build ./apps/api)" 60 | - name: Deploy to Cloud Run (api) 61 | run: | 62 | gcloud --quiet beta run deploy ${{ vars.CLOUD_RUN_SERVICE_NAME_API }} \ 63 | --image ${{ steps.build-api.outputs.image }} \ 64 | --labels environment=production \ 65 | --service-account ${{ vars.CLOUD_RUN_SERVICE_ACCOUNT }} \ 66 | --execution-environment gen1 --region us-central1 --platform managed --memory 512Mi --allow-unauthenticated \ 67 | --set-env-vars GITHUB_AUTH_TOKEN="${{ secrets.GH_AUTH_TOKEN }}" \ 68 | --set-env-vars CACHE_STORAGE_BUCKET="${{ vars.APP_CACHE_BUCKET }}" \ 69 | --set-env-vars APP_ENV="production" 70 | 71 | deploy-worker: 72 | environment: production 73 | runs-on: ubuntu-latest 74 | needs: [install-deps] 75 | steps: 76 | - uses: actions/checkout@v4 77 | with: 78 | ref: ${{ inputs.ref }} 79 | - id: 'auth' 80 | uses: 'google-github-actions/auth@v2' 81 | with: 82 | workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} 83 | service_account: ${{ vars.GOOGLE_DEPLOY_SERVICE_ACCOUNT }} 84 | - uses: google-github-actions/setup-gcloud@v2 85 | - uses: pnpm/action-setup@v4 86 | - uses: actions/setup-node@v4 87 | with: 88 | node-version-file: '.node-version' 89 | cache: pnpm 90 | - run: pnpm install --frozen-lockfile 91 | - run: pnpm build worker 92 | - name: Deploy to Cloud Run (worker) 93 | run: | 94 | gcloud --quiet run deploy ${{ vars.CLOUD_RUN_SERVICE_NAME_WORKER }} \ 95 | --source ./apps/worker \ 96 | --labels environment=production \ 97 | --service-account ${{ vars.CLOUD_RUN_SERVICE_ACCOUNT }} \ 98 | --region us-central1 --platform managed --memory 512Mi \ 99 | --set-env-vars APP_ENV="production" 100 | 101 | deploy-webapp: 102 | environment: production 103 | runs-on: ubuntu-latest 104 | needs: [deploy-api] 105 | steps: 106 | - uses: actions/checkout@v4 107 | with: 108 | ref: ${{ inputs.ref }} 109 | - id: 'auth' 110 | uses: 'google-github-actions/auth@v2' 111 | with: 112 | workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} 113 | service_account: ${{ vars.GOOGLE_DEPLOY_SERVICE_ACCOUNT }} 114 | - uses: pnpm/action-setup@v4 115 | - uses: actions/setup-node@v4 116 | with: 117 | node-version-file: '.node-version' 118 | cache: pnpm 119 | - run: pnpm install --frozen-lockfile 120 | - run: pnpm build:all:production 121 | - name: Deploy webapp to Firebase 122 | run: pnpm firebase deploy --project=${{ steps.auth.outputs.project_id }} --only=hosting:production,firestore 123 | -------------------------------------------------------------------------------- /.github/workflows/deploy-staging.yml: -------------------------------------------------------------------------------- 1 | name: 'Deploy (staging)' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: 'read' 10 | id-token: 'write' 11 | 12 | jobs: 13 | install-deps: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: pnpm/action-setup@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version-file: '.node-version' 21 | cache: pnpm 22 | - run: pnpm install --frozen-lockfile 23 | - uses: ./.github/actions/setup-go 24 | 25 | deploy-api: 26 | environment: staging 27 | runs-on: ubuntu-latest 28 | needs: [install-deps] 29 | steps: 30 | - uses: actions/checkout@v4 31 | - id: 'auth' 32 | uses: 'google-github-actions/auth@v2' 33 | with: 34 | workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} 35 | service_account: ${{ vars.GOOGLE_DEPLOY_SERVICE_ACCOUNT }} 36 | - uses: google-github-actions/setup-gcloud@v2 37 | - uses: ./.github/actions/setup-go 38 | - uses: imjasonh/setup-ko@v0.8 39 | env: 40 | KO_DOCKER_REPO: us-central1-docker.pkg.dev/${{ steps.auth.outputs.project_id }}/cloud-run-builds 41 | - name: Build Docker image of api 42 | id: 'build-api' 43 | run: echo "::set-output name=image::$(ko build ./apps/api)" 44 | - name: Deploy to Cloud Run (api) 45 | run: | 46 | gcloud --quiet beta run deploy ${{ vars.CLOUD_RUN_SERVICE_NAME_API }} \ 47 | --image ${{ steps.build-api.outputs.image }} \ 48 | --labels environment=staging \ 49 | --service-account ${{ vars.CLOUD_RUN_SERVICE_ACCOUNT }} \ 50 | --execution-environment gen1 --region us-central1 --platform managed --memory 128Mi --allow-unauthenticated \ 51 | --set-env-vars GITHUB_AUTH_TOKEN="${{ secrets.GH_AUTH_TOKEN }}" \ 52 | --set-env-vars CACHE_STORAGE_BUCKET="${{ vars.APP_CACHE_BUCKET }}" \ 53 | --set-env-vars APP_ENV="staging" 54 | 55 | deploy-worker: 56 | environment: staging 57 | runs-on: ubuntu-latest 58 | needs: [install-deps] 59 | steps: 60 | - uses: actions/checkout@v4 61 | - id: 'auth' 62 | uses: 'google-github-actions/auth@v2' 63 | with: 64 | workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} 65 | service_account: ${{ vars.GOOGLE_DEPLOY_SERVICE_ACCOUNT }} 66 | - uses: google-github-actions/setup-gcloud@v2 67 | - uses: pnpm/action-setup@v4 68 | - uses: actions/setup-node@v4 69 | with: 70 | node-version-file: '.node-version' 71 | cache: pnpm 72 | - run: pnpm install --frozen-lockfile 73 | - run: pnpm build worker 74 | - name: Deploy to Cloud Run (worker) 75 | run: | 76 | gcloud --quiet run deploy ${{ vars.CLOUD_RUN_SERVICE_NAME_WORKER }} \ 77 | --source ./apps/worker \ 78 | --labels environment=staging \ 79 | --service-account ${{ vars.CLOUD_RUN_SERVICE_ACCOUNT }} \ 80 | --region us-central1 --platform managed --memory 512Mi \ 81 | --set-env-vars APP_ENV="staging" 82 | 83 | deploy-webapp: 84 | environment: staging 85 | runs-on: ubuntu-latest 86 | needs: [deploy-api] 87 | steps: 88 | - uses: actions/checkout@v4 89 | - id: 'auth' 90 | uses: 'google-github-actions/auth@v2' 91 | with: 92 | workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} 93 | service_account: ${{ vars.GOOGLE_DEPLOY_SERVICE_ACCOUNT }} 94 | - uses: pnpm/action-setup@v4 95 | - uses: actions/setup-node@v4 96 | with: 97 | node-version-file: '.node-version' 98 | cache: pnpm 99 | - run: pnpm install --frozen-lockfile 100 | - run: pnpm build:all:staging 101 | - name: Deploy webapp to Firebase 102 | run: pnpm firebase deploy --project=${{ steps.auth.outputs.project_id }} --only=hosting:staging 103 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release-please: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | release_created: ${{ steps.release-please.outputs.release_created }} 13 | release_pr: ${{ steps.release-please.outputs.pr }} 14 | release_ref: ${{ steps.release-please.outputs.sha }} 15 | permissions: 16 | contents: write 17 | pull-requests: write 18 | steps: 19 | - uses: googleapis/release-please-action@v4 20 | id: release-please 21 | with: 22 | release-type: node 23 | package-name: contrib.rocks 24 | 25 | create-preview-comment: 26 | needs: 27 | - release-please 28 | if: ${{ needs.release-please.outputs.release_pr }} 29 | runs-on: ubuntu-latest 30 | permissions: 31 | contents: write 32 | issues: write 33 | pull-requests: write 34 | steps: 35 | - name: Find Comment 36 | uses: peter-evans/find-comment@v3 37 | id: fc 38 | with: 39 | issue-number: ${{ fromJSON(needs.release-please.outputs.release_pr).number }} 40 | comment-author: 'github-actions[bot]' 41 | body-includes: 'Preview:' 42 | - name: Create preview comment 43 | uses: peter-evans/create-or-update-comment@v4 44 | with: 45 | comment-id: ${{ steps.fc.outputs.comment-id }} 46 | issue-number: ${{ fromJSON(needs.release-please.outputs.release_pr).number }} 47 | body: | 48 | Preview: ![](https://stg.contrib.rocks/image?repo=angular/angular-ja) 49 | edit-mode: replace 50 | 51 | run-deploy: 52 | needs: 53 | - release-please 54 | if: ${{ needs.release-please.outputs.release_created }} 55 | permissions: 56 | contents: 'read' 57 | id-token: 'write' 58 | uses: ./.github/workflows/deploy-production.yml 59 | with: 60 | ref: ${{ needs.release-please.outputs.release_ref }} 61 | secrets: inherit 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events.json 15 | speed-measure-plugin.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | .firebase 26 | .credentials 27 | /.env 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | .history/* 36 | 37 | # misc 38 | /.angular/cache 39 | /.sass-cache 40 | /.nx 41 | /connect.lock 42 | /coverage 43 | /libpeerconnection.log 44 | npm-debug.log 45 | yarn-error.log 46 | testem.log 47 | /typings 48 | firebase-debug.log 49 | 50 | # System Files 51 | .DS_Store 52 | Thumbs.db 53 | 54 | 55 | # Added by cargo 56 | 57 | target 58 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.14.0 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shared-workspace-lockfile=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | target 4 | CHANGELOG.md 5 | pnpm-lock.yaml 6 | 7 | /.nx/cache 8 | /.nx/workspace-data -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "angular.ng-template", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # contributors-img 2 | 3 | App: https://contrib.rocks 4 | 5 | ## Demo 6 | 7 | ### Prod 8 | 9 | 10 | 11 | 12 | 13 | ### Staging 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /apps/api/go/apiclient/google.go: -------------------------------------------------------------------------------- 1 | package apiclient 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/bigquery" 7 | "cloud.google.com/go/firestore" 8 | "cloud.google.com/go/logging" 9 | "cloud.google.com/go/storage" 10 | ) 11 | 12 | func NewBigQueryClient() *bigquery.Client { 13 | c, err := bigquery.NewClient(context.Background(), bigquery.DetectProjectID) 14 | if err != nil { 15 | panic(err) 16 | } 17 | return c 18 | } 19 | 20 | func NewStorageClient() *storage.Client { 21 | c, err := storage.NewClient(context.Background()) 22 | if err != nil { 23 | panic(err) 24 | } 25 | return c 26 | } 27 | 28 | func NewLoggingClient(projectID string) *logging.Client { 29 | c, err := logging.NewClient(context.Background(), projectID) 30 | if err != nil { 31 | panic(err) 32 | } 33 | return c 34 | } 35 | 36 | func NewFirestoreClient() *firestore.Client { 37 | c, err := firestore.NewClient(context.Background(), firestore.DetectProjectID) 38 | if err != nil { 39 | panic(err) 40 | } 41 | return c 42 | } 43 | -------------------------------------------------------------------------------- /apps/api/go/compress/compress.go: -------------------------------------------------------------------------------- 1 | package compress 2 | 3 | import ( 4 | "compress/gzip" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/andybalholm/brotli" 11 | "github.com/gin-gonic/gin" 12 | "go.opentelemetry.io/otel" 13 | ) 14 | 15 | type compressHandler struct { 16 | gzPool sync.Pool 17 | brPool sync.Pool 18 | } 19 | 20 | func Compress() gin.HandlerFunc { 21 | return (&compressHandler{ 22 | gzPool: sync.Pool{ 23 | New: func() any { 24 | w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression) 25 | return w 26 | }, 27 | }, 28 | brPool: sync.Pool{ 29 | New: func() any { 30 | return brotli.NewWriterLevel(io.Discard, brotli.DefaultCompression) 31 | }, 32 | }, 33 | }).Handle 34 | } 35 | 36 | func (h *compressHandler) Handle(c *gin.Context) { 37 | if c.GetHeader("Accept-Encoding") == "" || 38 | strings.Contains(c.GetHeader("Connection"), "Upgrade") || 39 | strings.Contains(c.GetHeader("Accept"), "text/event-stream") { 40 | // skip compression 41 | return 42 | } 43 | 44 | if strings.Contains(c.GetHeader("Accept-Encoding"), "br") { 45 | h.handleBrotli(c) 46 | } else if strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") { 47 | h.handleGzip(c) 48 | } 49 | } 50 | 51 | func (h *compressHandler) handleBrotli(c *gin.Context) { 52 | ctx, span := otel.Tracer("compress").Start(c.Request.Context(), "compress.handleBrotli") 53 | defer span.End() 54 | c.Request = c.Request.WithContext(ctx) 55 | 56 | w := h.brPool.Get().(*brotli.Writer) 57 | defer h.brPool.Put(w) 58 | defer w.Reset(io.Discard) 59 | w.Reset(c.Writer) 60 | 61 | c.Header("Content-Encoding", "br") 62 | c.Header("Vary", "Accept-Encoding") 63 | c.Writer = &brotliWriter{c.Writer, w} 64 | defer func() { 65 | w.Close() 66 | c.Header("Content-Length", fmt.Sprint(c.Writer.Size())) 67 | }() 68 | c.Next() 69 | } 70 | 71 | func (h *compressHandler) handleGzip(c *gin.Context) { 72 | ctx, span := otel.Tracer("compress").Start(c.Request.Context(), "compress.handleGzip") 73 | defer span.End() 74 | c.Request = c.Request.WithContext(ctx) 75 | 76 | w := h.gzPool.Get().(*gzip.Writer) 77 | defer h.gzPool.Put(w) 78 | defer w.Reset(io.Discard) 79 | w.Reset(c.Writer) 80 | 81 | c.Header("Content-Encoding", "gzip") 82 | c.Header("Vary", "Accept-Encoding") 83 | c.Writer = &gzipWriter{c.Writer, w} 84 | defer func() { 85 | w.Close() 86 | c.Header("Content-Length", fmt.Sprint(c.Writer.Size())) 87 | }() 88 | c.Next() 89 | } 90 | 91 | type brotliWriter struct { 92 | gin.ResponseWriter 93 | writer *brotli.Writer 94 | } 95 | 96 | func (w *brotliWriter) WriteString(s string) (int, error) { 97 | w.Header().Del("Content-Length") 98 | return w.writer.Write([]byte(s)) 99 | } 100 | 101 | func (w *brotliWriter) Write(data []byte) (int, error) { 102 | w.Header().Del("Content-Length") 103 | return w.writer.Write(data) 104 | } 105 | 106 | func (w *brotliWriter) WriteHeader(code int) { 107 | w.Header().Del("Content-Length") 108 | w.ResponseWriter.WriteHeader(code) 109 | } 110 | 111 | type gzipWriter struct { 112 | gin.ResponseWriter 113 | writer *gzip.Writer 114 | } 115 | 116 | func (w *gzipWriter) WriteString(s string) (int, error) { 117 | w.Header().Del("Content-Length") 118 | return w.writer.Write([]byte(s)) 119 | } 120 | 121 | func (w *gzipWriter) Write(data []byte) (int, error) { 122 | w.Header().Del("Content-Length") 123 | return w.writer.Write(data) 124 | } 125 | 126 | func (w *gzipWriter) WriteHeader(code int) { 127 | w.Header().Del("Content-Length") 128 | w.ResponseWriter.WriteHeader(code) 129 | } 130 | -------------------------------------------------------------------------------- /apps/api/go/compress/compress_test.go: -------------------------------------------------------------------------------- 1 | package compress 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func TestCompress(t *testing.T) { 12 | t.Run("No compression with no accepted encoding", func(t *testing.T) { 13 | r := gin.New() 14 | r.Use(Compress()) 15 | r.GET("/test", func(c *gin.Context) { 16 | c.String(http.StatusOK, "test") 17 | }) 18 | 19 | w := httptest.NewRecorder() 20 | req, _ := http.NewRequest(http.MethodGet, "/test", nil) 21 | req.Header.Set("Accept-Encoding", "") 22 | r.ServeHTTP(w, req) 23 | 24 | if w.Header().Get("Content-Encoding") != "" { 25 | t.Fatalf("Expected no content encoding") 26 | } 27 | }) 28 | t.Run("Support gzip compression", func(t *testing.T) { 29 | r := gin.New() 30 | r.Use(Compress()) 31 | r.GET("/test", func(c *gin.Context) { 32 | c.String(http.StatusOK, "test") 33 | }) 34 | 35 | w := httptest.NewRecorder() 36 | req, _ := http.NewRequest(http.MethodGet, "/test", nil) 37 | req.Header.Set("Accept-Encoding", "gzip") 38 | r.ServeHTTP(w, req) 39 | 40 | if w.Header().Get("Content-Encoding") != "gzip" { 41 | t.Fatalf("Unsupported content encoding %s", w.Header().Get("Content-Encoding")) 42 | } 43 | }) 44 | t.Run("Support brotli compression", func(t *testing.T) { 45 | r := gin.New() 46 | r.Use(Compress()) 47 | r.GET("/test", func(c *gin.Context) { 48 | c.String(http.StatusOK, "test") 49 | }) 50 | 51 | w := httptest.NewRecorder() 52 | req, _ := http.NewRequest(http.MethodGet, "/test", nil) 53 | req.Header.Set("Accept-Encoding", "br") 54 | r.ServeHTTP(w, req) 55 | 56 | if w.Header().Get("Content-Encoding") != "br" { 57 | t.Fatalf("Unsupported content encoding %s", w.Header().Get("Content-Encoding")) 58 | } 59 | }) 60 | t.Run("Brotli has higher priority than gzip", func(t *testing.T) { 61 | r := gin.New() 62 | r.Use(Compress()) 63 | r.GET("/test", func(c *gin.Context) { 64 | c.String(http.StatusOK, "test") 65 | }) 66 | 67 | w := httptest.NewRecorder() 68 | req, _ := http.NewRequest(http.MethodGet, "/test", nil) 69 | req.Header.Set("Accept-Encoding", "gzip, br") 70 | r.ServeHTTP(w, req) 71 | 72 | if w.Header().Get("Content-Encoding") != "br" { 73 | t.Fatalf("Unsupported content encoding %s", w.Header().Get("Content-Encoding")) 74 | } 75 | }) 76 | 77 | } 78 | -------------------------------------------------------------------------------- /apps/api/go/dataurl/.snapshots/TestConvert: -------------------------------------------------------------------------------- 1 | data:image/svg+xml;base64,PHN2Zz48L3N2Zz4= 2 | -------------------------------------------------------------------------------- /apps/api/go/dataurl/resolver.go: -------------------------------------------------------------------------------- 1 | package dataurl 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | 11 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 12 | ) 13 | 14 | var DefaultHTTPClient = &http.Client{ 15 | Transport: otelhttp.NewTransport(http.DefaultTransport), 16 | } 17 | 18 | func Convert(c context.Context, remoteURL string, extraParams map[string]string) (string, error) { 19 | u, _ := url.Parse(remoteURL) 20 | q := u.Query() 21 | for k, v := range extraParams { 22 | q.Set(k, v) 23 | } 24 | u.RawQuery = q.Encode() 25 | 26 | req, err := http.NewRequestWithContext(c, http.MethodGet, u.String(), nil) 27 | if err != nil { 28 | return "", err 29 | } 30 | resp, err := DefaultHTTPClient.Do(req) 31 | if err != nil { 32 | return "", err 33 | } 34 | contentType := resp.Header.Get("Content-Type") 35 | body, err := io.ReadAll(resp.Body) 36 | if err != nil { 37 | return "", err 38 | } 39 | return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(body)), nil 40 | } 41 | -------------------------------------------------------------------------------- /apps/api/go/dataurl/resolver_test.go: -------------------------------------------------------------------------------- 1 | package dataurl 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/bradleyjkemp/cupaloy" 11 | ) 12 | 13 | func TestConvert(t *testing.T) { 14 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | w.Header().Set("Content-Type", "image/svg+xml") 16 | fmt.Fprint(w, "") 17 | })) 18 | defer ts.Close() 19 | 20 | ret, err := Convert(context.Background(), ts.URL, map[string]string{"s": "64"}) 21 | if err != nil { 22 | t.Fatalf(err.Error()) 23 | } 24 | cupaloy.SnapshotT(t, ret) 25 | } 26 | -------------------------------------------------------------------------------- /apps/api/go/env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | type Environment string 4 | 5 | const ( 6 | EnvDevelopment = Environment("development") 7 | EnvStaging = Environment("staging") 8 | EnvProduction = Environment("production") 9 | ) 10 | 11 | // FromString app environment from string 12 | func FromString(str string) Environment { 13 | switch str { 14 | case "production": 15 | return EnvProduction 16 | case "staging": 17 | return EnvStaging 18 | default: 19 | return EnvDevelopment 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /apps/api/go/env/env_test.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFromString(t *testing.T) { 8 | type args struct { 9 | str string 10 | } 11 | tests := []struct { 12 | name string 13 | args args 14 | want Environment 15 | }{ 16 | {"development", args{"development"}, EnvDevelopment}, 17 | {"staging", args{"staging"}, EnvStaging}, 18 | {"production", args{"production"}, EnvProduction}, 19 | {"unknown", args{"unknown"}, EnvDevelopment}, 20 | {"empty", args{""}, EnvDevelopment}, 21 | } 22 | for _, tt := range tests { 23 | t.Run(tt.name, func(t *testing.T) { 24 | if got := FromString(tt.args.str); got != tt.want { 25 | t.Errorf("FromString() = %v, want %v", got, tt.want) 26 | } 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/api/go/httptrace/httptrace.go: -------------------------------------------------------------------------------- 1 | package httptrace 2 | 3 | import ( 4 | "net/http" 5 | 6 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 7 | ) 8 | 9 | func NewTransport(base http.RoundTripper) http.RoundTripper { 10 | return otelhttp.NewTransport(base) 11 | } 12 | 13 | func NewClient(base http.RoundTripper) *http.Client { 14 | return &http.Client{Transport: NewTransport(base)} 15 | } 16 | -------------------------------------------------------------------------------- /apps/api/go/model/contributor.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type RepositoryContributors struct { 4 | *Repository 5 | StargazersCount int `json:"stargazersCount"` 6 | Contributors []*Contributor `json:"data"` 7 | } 8 | 9 | type Contributor struct { 10 | ID int64 `json:"id"` 11 | Login string `json:"login"` 12 | AvatarURL string `json:"avatar_url"` 13 | HTMLURL string `json:"html_url"` 14 | Contributions int `json:"contributions"` 15 | } 16 | -------------------------------------------------------------------------------- /apps/api/go/model/file.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "io" 4 | 5 | type FileHandle interface { 6 | Reader() io.ReadCloser 7 | ContentType() string 8 | Size() int64 9 | ETag() string 10 | } 11 | -------------------------------------------------------------------------------- /apps/api/go/model/repository.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // 'owner/repo' 10 | type RepositoryString string 11 | 12 | type Repository struct { 13 | Owner string `json:"owner"` 14 | RepoName string `json:"repo"` 15 | } 16 | 17 | func (r RepositoryString) Object() *Repository { 18 | parts := strings.SplitN(string(r), "/", 2) 19 | return &Repository{Owner: parts[0], RepoName: parts[1]} 20 | } 21 | 22 | func (r Repository) String() string { 23 | return r.Owner + "/" + r.RepoName 24 | } 25 | 26 | // RepositoryNotFoundError is returned when a repository is not found 27 | type RepositoryNotFoundError struct { 28 | Repository *Repository 29 | } 30 | 31 | func (e *RepositoryNotFoundError) Error() string { 32 | return "Repository not found: " + e.Repository.String() 33 | } 34 | 35 | func ValidateRepositoryName(s string) error { 36 | if s == "" { 37 | return fmt.Errorf("repository name cannot be empty") 38 | } 39 | if match, err := regexp.MatchString(`^[\w\-._]+\/[\w\-._]+$`, s); !match || err != nil { 40 | return fmt.Errorf("invalid repository name: %s", s) 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /apps/api/go/model/repository_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestValidateRepositoryName(t *testing.T) { 9 | type args struct { 10 | s string 11 | } 12 | tests := []struct { 13 | name string 14 | args args 15 | wantErr bool 16 | }{ 17 | {"empty", args{""}, true}, 18 | {"valid", args{"angular/angular-ja"}, false}, 19 | {"invalid", args{"angular-ja"}, true}, 20 | } 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | if err := ValidateRepositoryName(tt.args.s); (err != nil) != tt.wantErr { 24 | t.Errorf("ValidateRepositoryName() error = %v, wantErr %v", err, tt.wantErr) 25 | } 26 | }) 27 | } 28 | } 29 | 30 | func TestRepositoryString_Object(t *testing.T) { 31 | tests := []struct { 32 | name string 33 | r RepositoryString 34 | want *Repository 35 | }{ 36 | { 37 | name: "valid", 38 | r: "angular/angular-ja", 39 | want: &Repository{Owner: "angular", RepoName: "angular-ja"}, 40 | }, 41 | } 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | if got := tt.r.Object(); !reflect.DeepEqual(got, tt.want) { 45 | t.Errorf("RepositoryString.Object() = %v, want %v", got, tt.want) 46 | } 47 | }) 48 | } 49 | } 50 | 51 | func TestRepository_String(t *testing.T) { 52 | tests := []struct { 53 | name string 54 | r *Repository 55 | want string 56 | }{ 57 | { 58 | name: "valid", 59 | r: &Repository{Owner: "angular", RepoName: "angular-ja"}, 60 | want: "angular/angular-ja", 61 | }, 62 | } 63 | for _, tt := range tests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | if got := tt.r.String(); !reflect.DeepEqual(got, tt.want) { 66 | t.Errorf("Repository.String() = %v, want %v", got, tt.want) 67 | } 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /apps/api/go/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "golib", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "namedInputs": { 5 | "default": ["{workspaceRoot}/go.mod", "{projectRoot}/**/*"], 6 | "app": ["!{projectRoot}/**/*_spec.go"] 7 | }, 8 | "tags": ["lib"], 9 | "targets": { 10 | "test": { 11 | "executor": "nx:run-commands", 12 | "inputs": ["default"], 13 | "options": { 14 | "command": "go test ./..." 15 | // "cwd": "libs/go" 16 | } 17 | }, 18 | "lint": { 19 | "executor": "nx:run-commands", 20 | "inputs": ["default"], 21 | "options": { 22 | "command": "go vet ./..." 23 | // "cwd": "libs/go" 24 | } 25 | }, 26 | "format": { 27 | "executor": "nx:run-commands", 28 | "inputs": ["default"], 29 | "options": { 30 | "command": "gofmt -w ." 31 | // "cwd": "libs/go" 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/api/go/renderer/.snapshots/TestRender_Snapshot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | \nlogin1 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | \nlogin2 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /apps/api/go/renderer/image.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import "contrib.rocks/apps/api/go/model" 4 | 5 | type Image interface { 6 | model.FileHandle 7 | Bytes() []byte 8 | } 9 | -------------------------------------------------------------------------------- /apps/api/go/renderer/renderer.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "fmt" 7 | "io" 8 | "math" 9 | 10 | "contrib.rocks/apps/api/go/model" 11 | svg "github.com/ajstarks/svgo" 12 | ) 13 | 14 | type RendererOptions struct { 15 | MaxCount int 16 | Columns int 17 | ItemSize int 18 | } 19 | 20 | type renderer struct { 21 | Options *RendererOptions 22 | } 23 | 24 | func NewRenderer(o *RendererOptions) *renderer { 25 | return &renderer{o} 26 | } 27 | 28 | var _ Image = &svgImage{} 29 | 30 | type svgImage []byte 31 | 32 | func (i svgImage) Reader() io.ReadCloser { 33 | return io.NopCloser(bytes.NewReader(i)) 34 | } 35 | func (i svgImage) Size() int64 { 36 | return int64(len(i)) 37 | } 38 | func (i svgImage) ContentType() string { 39 | return "image/svg+xml" 40 | } 41 | func (i svgImage) ETag() string { 42 | return fmt.Sprintf("%x", md5.Sum(i.Bytes())) 43 | } 44 | func (i svgImage) Bytes() []byte { 45 | return i 46 | } 47 | 48 | func (r *renderer) Render(data *model.RepositoryContributors) Image { 49 | w := bytes.NewBuffer(nil) 50 | r.buildSVG(w, data) 51 | return svgImage(w.Bytes()) 52 | } 53 | 54 | func (r *renderer) buildSVG(w io.Writer, data *model.RepositoryContributors) { 55 | itemCount := len(data.Contributors) 56 | columns := math.Min(float64(r.Options.Columns), float64(itemCount)) 57 | rows := math.Ceil(float64(itemCount) / float64(columns)) 58 | gap := 4 59 | width := float64(r.Options.ItemSize)*columns + float64(gap)*(columns-1) 60 | height := float64(r.Options.ItemSize)*rows + float64(gap)*(rows-1) 61 | 62 | canvas := svg.New(w) 63 | canvas.Start(int(width), int(height), fmt.Sprintf(`viewBox="0 0 %d %d"`, int(width), int(height))) 64 | for i, c := range data.Contributors { 65 | x := (i % int(columns)) * (r.Options.ItemSize + gap) 66 | y := (i / int(columns)) * (r.Options.ItemSize + gap) 67 | // nested is not supported in svgo 68 | fmt.Fprintf(canvas.Writer, `\n`, x, y, r.Options.ItemSize, r.Options.ItemSize) 69 | { 70 | fillId := fmt.Sprintf("fill%d", i) 71 | canvas.Title(c.Login) 72 | canvas.Circle(r.Options.ItemSize/2, r.Options.ItemSize/2, r.Options.ItemSize/2, `stroke="#c0c0c0"`, `stroke-width="1"`, fmt.Sprintf(`fill="url(#%s)"`, fillId)) 73 | canvas.Def() 74 | { 75 | canvas.Pattern(fillId, 0, 0, r.Options.ItemSize, r.Options.ItemSize, "user") 76 | { 77 | canvas.Image(0, 0, r.Options.ItemSize, r.Options.ItemSize, c.AvatarURL) 78 | } 79 | canvas.PatternEnd() 80 | } 81 | canvas.DefEnd() 82 | } 83 | canvas.End() 84 | } 85 | canvas.End() 86 | } 87 | -------------------------------------------------------------------------------- /apps/api/go/renderer/renderer_test.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "contrib.rocks/apps/api/go/model" 10 | "github.com/bradleyjkemp/cupaloy" 11 | ) 12 | 13 | func TestRender_Snapshot(t *testing.T) { 14 | r := NewRenderer(&RendererOptions{MaxCount: 12, Columns: 4, ItemSize: 64}) 15 | ret := r.Render(&model.RepositoryContributors{ 16 | Repository: &model.Repository{Owner: "owner", RepoName: "name"}, 17 | StargazersCount: 100, 18 | Contributors: []*model.Contributor{ 19 | {Login: "login1", AvatarURL: "avatar1", Contributions: 1}, 20 | {Login: "login2", AvatarURL: "avatar2", Contributions: 2}, 21 | }, 22 | }) 23 | svgStr := string(ret.Bytes()) 24 | cupaloy.New(cupaloy.SnapshotFileExtension(".svg")).SnapshotT(t, svgStr) 25 | } 26 | 27 | func TestRender_Title(t *testing.T) { 28 | r := NewRenderer(&RendererOptions{MaxCount: 12, Columns: 4, ItemSize: 64}) 29 | ret := r.Render(&model.RepositoryContributors{ 30 | Repository: &model.Repository{Owner: "owner", RepoName: "name"}, 31 | StargazersCount: 100, 32 | Contributors: []*model.Contributor{ 33 | {Login: "login1", AvatarURL: "avatar1", Contributions: 1}, 34 | {Login: "login2", AvatarURL: "avatar2", Contributions: 2}, 35 | }, 36 | }) 37 | svgStr := string(ret.Bytes()) 38 | for _, s := range []string{"login1", "login2"} { 39 | if !strings.Contains(svgStr, s) { 40 | t.Log(svgStr) 41 | t.Fatal("title not found") 42 | } 43 | } 44 | } 45 | 46 | func TestSvgImage_Size(t *testing.T) { 47 | img := svgImage([]byte("")) 48 | if img.Size() != 11 { 49 | t.Fatalf("size not correct: %d", img.Size()) 50 | } 51 | } 52 | 53 | func TestSvgImage_ContentType(t *testing.T) { 54 | t.Run("should be image/svg+xml", func(tt *testing.T) { 55 | img := svgImage([]byte("")) 56 | if img.ContentType() != "image/svg+xml" { 57 | tt.Fatalf("content type not correct: %s", img.ContentType()) 58 | } 59 | }) 60 | } 61 | 62 | func TestSvgImage_ETag(t *testing.T) { 63 | t.Run("should be a MD5 hash", func(tt *testing.T) { 64 | img := svgImage([]byte("")) 65 | if img.ETag() != fmt.Sprintf("%x", md5.Sum([]byte(""))) { 66 | tt.Fatalf("etag not correct: %s", img.ETag()) 67 | } 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /apps/api/go/util/fns.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "golang.org/x/exp/constraints" 4 | 5 | func Min[T constraints.Ordered](a, b T) T { 6 | if a < b { 7 | return a 8 | } 9 | return b 10 | } 11 | -------------------------------------------------------------------------------- /apps/api/go/util/fns_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestMin_Int(t *testing.T) { 9 | type args struct { 10 | a int 11 | b int 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | want int 17 | }{ 18 | {"1 < 2", args{1, 2}, 1}, 19 | {"2 > 1", args{2, 1}, 1}, 20 | {"1 = 1", args{1, 1}, 1}, 21 | } 22 | for _, tt := range tests { 23 | t.Run(tt.name, func(t *testing.T) { 24 | if got := Min(tt.args.a, tt.args.b); !reflect.DeepEqual(got, tt.want) { 25 | t.Errorf("Min() = %v, want %v", got, tt.want) 26 | } 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/api/internal/api/image/api.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "contrib.rocks/apps/api/go/model" 9 | "contrib.rocks/apps/api/go/renderer" 10 | "contrib.rocks/apps/api/internal/logger" 11 | "contrib.rocks/apps/api/internal/tracing" 12 | "github.com/gin-gonic/gin" 13 | "go.opentelemetry.io/otel/attribute" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | const ( 18 | imageMaxAge = 60 * 60 * 24 * 3 // 3 days 19 | ) 20 | 21 | type ImageService interface { 22 | GetImage(ctx context.Context, repo *model.Repository, options *renderer.RendererOptions, includeAnonymous bool) (model.FileHandle, error) 23 | RenderImage(ctx context.Context, data *model.RepositoryContributors, options *renderer.RendererOptions, includeAnonymous bool) (model.FileHandle, error) 24 | } 25 | 26 | type ContributorsService interface { 27 | GetContributors(ctx context.Context, repo *model.Repository) (*model.RepositoryContributors, error) 28 | } 29 | 30 | type UsageService interface { 31 | CollectUsage(c context.Context, r *model.RepositoryContributors, via string) error 32 | } 33 | 34 | type API struct { 35 | cs ContributorsService 36 | is ImageService 37 | us UsageService 38 | } 39 | 40 | func New(cs ContributorsService, is ImageService, us UsageService) *API { 41 | return &API{cs, is, us} 42 | } 43 | 44 | func (api *API) Get(c *gin.Context) { 45 | ctx, span := tracing.Tracer().Start(c.Request.Context(), "api.image.Get") 46 | defer span.End() 47 | log := logger.LoggerFromContext(ctx) 48 | var params GetImageParams 49 | if err := params.bind(c); err != nil { 50 | log.Error(err.Error()) 51 | c.String(http.StatusBadRequest, err.Error()) 52 | return 53 | } 54 | span.SetAttributes( 55 | attribute.String("/app/api/image/params/repository", string(params.Repository)), 56 | attribute.String("/app/api/image/params/via", params.Via), 57 | attribute.String("/app/api/image/params/referer", params.Referer), 58 | attribute.Int64("/app/api/image/params/max", int64(params.MaxCount)), 59 | attribute.Int64("/app/api/image/params/columns", int64(params.Columns)), 60 | ) 61 | log = log.With(logger.Label("repository", string(params.Repository)), 62 | logger.Label("referer", params.Referer)) 63 | ctx = logger.ContextWithLogger(ctx, log) 64 | 65 | log.Info(fmt.Sprintf("[api.image.Get] start: %s", params.Repository), zap.Object("params", params)) 66 | defer log.Info(fmt.Sprintf("[api.image.Get] end: %s", params.Repository)) 67 | 68 | var image model.FileHandle 69 | rendererOptions := &renderer.RendererOptions{ 70 | MaxCount: params.MaxCount, 71 | Columns: params.Columns, 72 | } 73 | 74 | // get image 75 | image, err := api.is.GetImage(ctx, params.Repository.Object(), rendererOptions, params.IncludeAnonymous) 76 | if err != nil { 77 | c.Error(err).SetType(gin.ErrorTypePublic) 78 | return 79 | } 80 | if image != nil { 81 | sendImage(c, image) 82 | return 83 | } 84 | 85 | // get data 86 | data, err := api.cs.GetContributors(ctx, params.Repository.Object()) 87 | if notfound, ok := err.(*model.RepositoryNotFoundError); ok { 88 | log.Error(err.Error()) 89 | c.String(http.StatusNotFound, notfound.Error()) 90 | return 91 | } else if err != nil { 92 | c.Error(err).SetType(gin.ErrorTypePublic) 93 | return 94 | } 95 | 96 | // render image 97 | image, err = api.is.RenderImage(ctx, data, rendererOptions, params.IncludeAnonymous) 98 | if err != nil { 99 | c.Error(err).SetType(gin.ErrorTypePublic) 100 | return 101 | } 102 | api.us.CollectUsage(ctx, data, params.Via) 103 | sendImage(c, image) 104 | } 105 | 106 | func sendImage(c *gin.Context, image model.FileHandle) { 107 | if c.GetHeader("If-None-Match") == image.ETag() { 108 | c.Status(http.StatusNotModified) 109 | c.Header("cache-control", fmt.Sprintf("public, max-age=%d", imageMaxAge)) 110 | return 111 | } 112 | r := image.Reader() 113 | defer r.Close() 114 | c.DataFromReader(http.StatusOK, image.Size(), image.ContentType(), r, map[string]string{ 115 | "cache-control": fmt.Sprintf(`public, max-age=%d`, imageMaxAge), 116 | "etag": image.ETag(), 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /apps/api/internal/api/image/params.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "strings" 5 | 6 | "contrib.rocks/apps/api/go/model" 7 | "github.com/gin-gonic/gin" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | type GetImageParams struct { 12 | Repository model.RepositoryString `form:"repo" binding:"required"` 13 | MaxCount int `form:"max"` 14 | Columns int `form:"columns"` 15 | IncludeAnonymous bool `form:"anon"` 16 | Preview bool `form:"preview"` 17 | Via string 18 | Referer string 19 | } 20 | 21 | // MarshalLogObject implements zapcore.ObjectMarshaler 22 | func (p GetImageParams) MarshalLogObject(enc zapcore.ObjectEncoder) error { 23 | enc.AddString("repository", string(p.Repository)) 24 | enc.AddInt("max", p.MaxCount) 25 | enc.AddInt("columns", p.Columns) 26 | enc.AddBool("anon", p.IncludeAnonymous) 27 | enc.AddBool("preview", p.Preview) 28 | enc.AddString("via", p.Via) 29 | enc.AddString("referer", p.Referer) 30 | return nil 31 | } 32 | 33 | func (p *GetImageParams) bind(ctx *gin.Context) error { 34 | if err := ctx.ShouldBindQuery(p); err != nil { 35 | return err 36 | } 37 | // validate repository name format 38 | if err := model.ValidateRepositoryName(string(p.Repository)); err != nil { 39 | return err 40 | } 41 | p.Via = "unknown" 42 | if strings.Contains(ctx.Request.UserAgent(), "github") { 43 | p.Via = "github" 44 | } else if strings.HasSuffix(ctx.Request.Host, "contrib.rocks") { 45 | p.Via = "preview" 46 | } 47 | p.Referer = ctx.Request.Referer() 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /apps/api/internal/api/image/params_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | func Test_GetImageParams_BindQuery_AllParams(t *testing.T) { 13 | uri := url.URL{Path: "/image"} 14 | q := uri.Query() 15 | q.Set("repo", "angular/angular-ja") 16 | q.Set("max", "100") 17 | q.Set("columns", "12") 18 | q.Set("anon", "1") 19 | q.Set("preview", "1") 20 | uri.RawQuery = q.Encode() 21 | c, _ := gin.CreateTestContext(httptest.NewRecorder()) 22 | c.Request, _ = http.NewRequest("GET", uri.String(), nil) 23 | 24 | var params GetImageParams 25 | err := params.bind(c) 26 | 27 | if err != nil { 28 | t.Fatalf(err.Error()) 29 | } 30 | if params.Repository != "angular/angular-ja" { 31 | t.Fatalf("Bound params: %v", params) 32 | } 33 | if params.MaxCount != 100 { 34 | t.Fatalf("Bound params: %v", params) 35 | } 36 | if params.Columns != 12 { 37 | t.Fatalf("Bound params: %v", params) 38 | } 39 | if !params.IncludeAnonymous { 40 | t.Fatalf("Bound params: %v", params) 41 | } 42 | if !params.Preview { 43 | t.Fatalf("Bound params: %v", params) 44 | } 45 | } 46 | 47 | func Test_GetImageParams_BingQuery_NoRepository(t *testing.T) { 48 | uri := url.URL{Path: "/image"} 49 | q := uri.Query() 50 | uri.RawQuery = q.Encode() 51 | c, _ := gin.CreateTestContext(httptest.NewRecorder()) 52 | c.Request, _ = http.NewRequest("GET", uri.String(), nil) 53 | 54 | var params GetImageParams 55 | err := params.bind(c) 56 | 57 | if err == nil { 58 | t.Fatalf(err.Error()) 59 | } 60 | } 61 | 62 | func Test_GetImageParams_BingQuery_InvalidRepository(t *testing.T) { 63 | uri := url.URL{Path: "/image"} 64 | q := uri.Query() 65 | q.Set("repo", "angular-ja") 66 | uri.RawQuery = q.Encode() 67 | c, _ := gin.CreateTestContext(httptest.NewRecorder()) 68 | c.Request, _ = http.NewRequest("GET", uri.String(), nil) 69 | 70 | var params GetImageParams 71 | err := params.bind(c) 72 | 73 | if err == nil { 74 | t.Fatalf(err.Error()) 75 | } 76 | } 77 | 78 | func Test_GetImageParams_BingQuery_Anon_ZeroIsFalse(t *testing.T) { 79 | uri := url.URL{Path: "/image"} 80 | q := uri.Query() 81 | q.Set("anon", "0") 82 | uri.RawQuery = q.Encode() 83 | c, _ := gin.CreateTestContext(httptest.NewRecorder()) 84 | c.Request, _ = http.NewRequest("GET", uri.String(), nil) 85 | 86 | var params GetImageParams 87 | err := params.bind(c) 88 | 89 | if err == nil { 90 | t.Fatalf(err.Error()) 91 | } 92 | if params.IncludeAnonymous { 93 | t.Fatalf("Bound params: %v", params) 94 | } 95 | } 96 | 97 | func Test_GetImageParams_BingQuery_Anon_FalseIsFalse(t *testing.T) { 98 | uri := url.URL{Path: "/image"} 99 | q := uri.Query() 100 | q.Set("anon", "false") 101 | uri.RawQuery = q.Encode() 102 | c, _ := gin.CreateTestContext(httptest.NewRecorder()) 103 | c.Request, _ = http.NewRequest("GET", uri.String(), nil) 104 | 105 | var params GetImageParams 106 | err := params.bind(c) 107 | 108 | if err == nil { 109 | t.Fatalf(err.Error()) 110 | } 111 | if params.IncludeAnonymous { 112 | t.Fatalf("Bound params: %v", params) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /apps/api/internal/api/routes.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "contrib.rocks/apps/api/internal/api/image" 5 | "contrib.rocks/apps/api/internal/service" 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | type Router interface { 10 | gin.IRouter 11 | } 12 | 13 | func Setup(r Router, sp *service.ServicePack) { 14 | imageApi := image.New(sp.ContributorsService, sp.ImageService, sp.UsageService) 15 | r.GET("/image", imageApi.Get) 16 | } 17 | -------------------------------------------------------------------------------- /apps/api/internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "contrib.rocks/apps/api/go/env" 9 | "golang.org/x/oauth2/google" 10 | ) 11 | 12 | type Config struct { 13 | Port string 14 | Env env.Environment 15 | GitHubAuthToken string 16 | CacheBucketName string 17 | 18 | googleCredentials *google.Credentials 19 | } 20 | 21 | func (c *Config) GoogleCredentials() *google.Credentials { 22 | return c.googleCredentials 23 | } 24 | 25 | func (c *Config) ProjectID() string { 26 | if c.googleCredentials != nil { 27 | return c.googleCredentials.ProjectID 28 | } 29 | return "" 30 | } 31 | 32 | func Load() (*Config, error) { 33 | var config Config 34 | config.Port = os.Getenv("PORT") 35 | if config.Port == "" { 36 | config.Port = "3333" 37 | } 38 | config.Env = env.FromString(os.Getenv("APP_ENV")) 39 | config.GitHubAuthToken = os.Getenv("GITHUB_AUTH_TOKEN") 40 | if config.GitHubAuthToken == "" { 41 | return nil, fmt.Errorf("GITHUB_AUTH_TOKEN is required") 42 | } 43 | config.CacheBucketName = os.Getenv("CACHE_STORAGE_BUCKET") 44 | config.googleCredentials = findGoogleCredentials() 45 | return &config, nil 46 | } 47 | 48 | func findGoogleCredentials() *google.Credentials { 49 | cred, _ := google.FindDefaultCredentials(context.Background()) 50 | return cred 51 | } 52 | -------------------------------------------------------------------------------- /apps/api/internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "contrib.rocks/apps/api/go/env" 8 | "github.com/joho/godotenv" 9 | ) 10 | 11 | func prepareEnv(t *testing.T) { 12 | os.Clearenv() 13 | err := godotenv.Load("../testing/.env") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | t.Cleanup(func() { 18 | os.Clearenv() 19 | }) 20 | } 21 | 22 | func TestConfig_Load(t *testing.T) { 23 | t.Run("PORT is 3333 by default", func(t *testing.T) { 24 | prepareEnv(t) 25 | config, err := Load() 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | if config.Port != "3333" { 30 | t.Fatalf("Expected port to be 3333, got %s", config.Port) 31 | } 32 | }) 33 | t.Run("PORT is set", func(t *testing.T) { 34 | prepareEnv(t) 35 | os.Setenv("PORT", "9000") 36 | config, err := Load() 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | if config.Port != "9000" { 41 | t.Fatalf("Expected port to be 9000, got %s", config.Port) 42 | } 43 | }) 44 | t.Run("GITHUB_AUTH_TOKEN is required", func(t *testing.T) { 45 | prepareEnv(t) 46 | os.Setenv("GITHUB_AUTH_TOKEN", "") 47 | config, err := Load() 48 | if err == nil { 49 | t.Fatalf("Expected error, got nil: %+v", config) 50 | } 51 | }) 52 | t.Run("APP_ENV is development by default", func(t *testing.T) { 53 | prepareEnv(t) 54 | config, err := Load() 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | if config.Env != env.EnvDevelopment { 59 | t.Fatalf("Expected env to be development, got %s", config.Env) 60 | } 61 | }) 62 | t.Run("APP_ENV is set", func(tt *testing.T) { 63 | prepareEnv(t) 64 | os.Setenv("APP_ENV", "staging") 65 | config, err := Load() 66 | if err != nil { 67 | tt.Fatal(err) 68 | } 69 | if config.Env != env.EnvStaging { 70 | tt.Fatalf("Expected env to be staging, got %s", config.Env) 71 | } 72 | }) 73 | t.Run("CACHE_STORAGE_BUCKET is empty by default", func(t *testing.T) { 74 | prepareEnv(t) 75 | config, err := Load() 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | if config.CacheBucketName != "" { 80 | t.Fatalf("Expected cache bucket name to be empty, got %s", config.CacheBucketName) 81 | } 82 | }) 83 | t.Run("CACHE_STORAGE_BUCKET is set", func(t *testing.T) { 84 | prepareEnv(t) 85 | os.Setenv("CACHE_STORAGE_BUCKET", "test") 86 | config, err := Load() 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | if config.CacheBucketName != "test" { 91 | t.Fatalf("Expected cache bucket name to be test, got %s", config.CacheBucketName) 92 | } 93 | }) 94 | t.Run("ProjectID is empty by default", func(t *testing.T) { 95 | prepareEnv(t) 96 | config, err := Load() 97 | if err != nil { 98 | t.Fatal(err) 99 | } 100 | if config.ProjectID() != "" { 101 | t.Fatalf("Expected project ID to be empty, got %s", config.ProjectID()) 102 | } 103 | }) 104 | } 105 | -------------------------------------------------------------------------------- /apps/api/internal/config/middleware.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | type contextKey string 10 | 11 | const configContextKey = contextKey("config") 12 | 13 | func Middleware(cfg *Config) gin.HandlerFunc { 14 | return func(c *gin.Context) { 15 | ctx := context.WithValue(c.Request.Context(), configContextKey, cfg) 16 | c.Request = c.Request.WithContext(ctx) 17 | c.Next() 18 | } 19 | } 20 | 21 | func FromContext(ctx context.Context) *Config { 22 | return ctx.Value(configContextKey).(*Config) 23 | } 24 | -------------------------------------------------------------------------------- /apps/api/internal/config/testing.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "contrib.rocks/apps/api/go/env" 5 | "golang.org/x/oauth2/google" 6 | ) 7 | 8 | func NewTestConfig() *Config { 9 | return &Config{ 10 | Port: "3333", 11 | Env: env.EnvDevelopment, 12 | GitHubAuthToken: "test", 13 | CacheBucketName: "", 14 | 15 | googleCredentials: &google.Credentials{ 16 | ProjectID: "test", 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/api/internal/github/api/github.go: -------------------------------------------------------------------------------- 1 | // Package api provides utilities for GitHub API operations 2 | package api 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "net" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | "time" 12 | 13 | "contrib.rocks/apps/api/go/model" 14 | "github.com/avast/retry-go/v4" 15 | "github.com/google/go-github/v69/github" 16 | ) 17 | 18 | // ErrorType represents the categorized GitHub error types 19 | type ErrorType int 20 | 21 | const ( 22 | ErrorTypeUnknown ErrorType = iota 23 | ErrorTypeTimeout 24 | ErrorTypeServer 25 | ErrorTypeConnectionRefused 26 | ErrorTypeNotFound 27 | ErrorTypeRateLimit 28 | ErrorTypeAbuseRateLimit 29 | ErrorTypeUnauthorized 30 | ErrorTypeForbidden 31 | ErrorTypeClientError 32 | ) 33 | 34 | // errorTypeMapping defines a mapping between error conditions and error types 35 | var errorTypeMapping = []struct { 36 | check func(error) bool 37 | errorType ErrorType 38 | }{ 39 | { 40 | check: func(err error) bool { 41 | var netErr net.Error 42 | return errors.As(err, &netErr) && netErr.Timeout() 43 | }, 44 | errorType: ErrorTypeTimeout, 45 | }, 46 | { 47 | check: func(err error) bool { 48 | var rateLimitErr *github.RateLimitError 49 | return errors.As(err, &rateLimitErr) 50 | }, 51 | errorType: ErrorTypeRateLimit, 52 | }, 53 | { 54 | check: func(err error) bool { 55 | var abuseErr *github.AbuseRateLimitError 56 | return errors.As(err, &abuseErr) 57 | }, 58 | errorType: ErrorTypeAbuseRateLimit, 59 | }, 60 | { 61 | check: func(err error) bool { 62 | var opErr *net.OpError 63 | return errors.As(err, &opErr) && opErr.Op == "dial" && 64 | strings.Contains(opErr.Error(), "connection refused") 65 | }, 66 | errorType: ErrorTypeConnectionRefused, 67 | }, 68 | { 69 | check: func(err error) bool { 70 | var urlErr *url.Error 71 | return errors.As(err, &urlErr) && 72 | strings.Contains(urlErr.Error(), "connection refused") 73 | }, 74 | errorType: ErrorTypeConnectionRefused, 75 | }, 76 | } 77 | 78 | // statusCodeMapping maps HTTP status codes to error types 79 | var statusCodeMapping = map[int]ErrorType{ 80 | http.StatusNotFound: ErrorTypeNotFound, 81 | http.StatusUnauthorized: ErrorTypeUnauthorized, 82 | http.StatusForbidden: ErrorTypeForbidden, 83 | } 84 | 85 | // GetErrorType determines the type of GitHub error 86 | func GetErrorType(err error, resp *github.Response) ErrorType { 87 | if err == nil { 88 | if resp != nil && resp.StatusCode == http.StatusNotFound { 89 | return ErrorTypeNotFound 90 | } 91 | return ErrorTypeUnknown 92 | } 93 | 94 | // Check specific error types 95 | for _, mapping := range errorTypeMapping { 96 | if mapping.check(err) { 97 | return mapping.errorType 98 | } 99 | } 100 | 101 | // Check GitHub API response status codes 102 | var githubErr *github.ErrorResponse 103 | if errors.As(err, &githubErr) && githubErr.Response != nil { 104 | statusCode := githubErr.Response.StatusCode 105 | 106 | if errorType, ok := statusCodeMapping[statusCode]; ok { 107 | return errorType 108 | } 109 | 110 | if statusCode >= 500 { 111 | return ErrorTypeServer 112 | } 113 | if statusCode >= 400 { 114 | return ErrorTypeClientError 115 | } 116 | } 117 | 118 | return ErrorTypeUnknown 119 | } 120 | 121 | // IsRetryableError checks if an error is retryable 122 | func IsRetryableError(err error) bool { 123 | errorType := GetErrorType(err, nil) 124 | return errorType == ErrorTypeTimeout || 125 | errorType == ErrorTypeServer || 126 | errorType == ErrorTypeConnectionRefused || 127 | errorType == ErrorTypeRateLimit || 128 | errorType == ErrorTypeAbuseRateLimit 129 | } 130 | 131 | // IsNotFoundError checks if an error is a not found error 132 | func IsNotFoundError(err error, resp *github.Response) bool { 133 | return GetErrorType(err, resp) == ErrorTypeNotFound 134 | } 135 | 136 | // GetRetryOptions returns standard retry options for GitHub API calls 137 | func GetRetryOptions(ctx context.Context) []retry.Option { 138 | return []retry.Option{ 139 | retry.Attempts(3), 140 | retry.DelayType(retry.BackOffDelay), 141 | retry.RetryIf(func(err error) bool { 142 | return IsRetryableError(err) 143 | }), 144 | retry.OnRetry(func(n uint, err error) { 145 | var rateLimitErr *github.RateLimitError 146 | if errors.As(err, &rateLimitErr) && rateLimitErr.Rate.Reset.Time.After(time.Now()) { 147 | waitTime := time.Until(rateLimitErr.Rate.Reset.Time) 148 | if waitTime > 0 { 149 | select { 150 | case <-time.After(waitTime): 151 | case <-ctx.Done(): 152 | return 153 | } 154 | } 155 | } 156 | }), 157 | retry.Context(ctx), 158 | } 159 | } 160 | 161 | // Call executes a GitHub API call with retry logic 162 | func Call[T any]( 163 | ctx context.Context, 164 | call func() (T, *github.Response, error), 165 | errorHandler func(error, *github.Response) error, 166 | ) (T, *github.Response, error) { 167 | var result T 168 | var resp *github.Response 169 | 170 | err := retry.Do( 171 | func() error { 172 | var callErr error 173 | result, resp, callErr = call() 174 | if errorHandler != nil { 175 | return errorHandler(callErr, resp) 176 | } 177 | return callErr 178 | }, 179 | GetRetryOptions(ctx)..., 180 | ) 181 | 182 | if err != nil { 183 | return result, resp, err 184 | } 185 | 186 | return result, resp, nil 187 | } 188 | 189 | // HandleRepositoryNotFoundError converts not found errors to a RepositoryNotFoundError 190 | func HandleRepositoryNotFoundError(err error, resp *github.Response, repo *model.Repository) error { 191 | if err != nil && IsNotFoundError(err, resp) { 192 | return retry.Unrecoverable(&model.RepositoryNotFoundError{Repository: repo}) 193 | } 194 | return err 195 | } 196 | -------------------------------------------------------------------------------- /apps/api/internal/github/provider.go: -------------------------------------------------------------------------------- 1 | // Package github provides GitHub API client functionality 2 | package github 3 | 4 | import ( 5 | "context" 6 | "sync" 7 | 8 | "contrib.rocks/apps/api/go/httptrace" 9 | "github.com/google/go-github/v69/github" 10 | "golang.org/x/oauth2" 11 | ) 12 | 13 | type provider struct { 14 | pool sync.Pool 15 | } 16 | 17 | func NewProvider(token string) *provider { 18 | return &provider{ 19 | pool: sync.Pool{ 20 | New: func() any { 21 | oc := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource( 22 | &oauth2.Token{AccessToken: token}, 23 | )) 24 | oc.Transport = httptrace.NewTransport(oc.Transport) 25 | return github.NewClient(oc) 26 | }, 27 | }, 28 | } 29 | } 30 | 31 | func (f *provider) Get() *github.Client { 32 | client := f.pool.Get().(*github.Client) 33 | f.pool.Put(client) 34 | return client 35 | } 36 | -------------------------------------------------------------------------------- /apps/api/internal/logger/label.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "go.ajitem.com/zapdriver" 5 | "go.uber.org/zap/zapcore" 6 | ) 7 | 8 | const ( 9 | logGroupIDKey = "groupId" 10 | ) 11 | 12 | func LogGroup(groupID string) zapcore.Field { 13 | return zapdriver.Labels(zapdriver.Label(logGroupIDKey, groupID)) 14 | } 15 | 16 | func Label(key, value string) zapcore.Field { 17 | return zapdriver.Labels(zapdriver.Label(key, value)) 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "contrib.rocks/apps/api/go/env" 5 | "contrib.rocks/apps/api/internal/config" 6 | "go.ajitem.com/zapdriver" 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | func buildBaseLogger(cfg *config.Config) *zap.Logger { 12 | var zc zap.Config 13 | if cfg.Env == env.EnvDevelopment { 14 | zc = zapdriver.NewDevelopmentConfig() 15 | } else if cfg.Env == env.EnvStaging { 16 | zc = zapdriver.NewProductionConfig() 17 | zc.Level = zap.NewAtomicLevelAt(zap.DebugLevel) 18 | zc.EncoderConfig.TimeKey = zapcore.OmitKey 19 | } else { 20 | zc = zapdriver.NewProductionConfig() 21 | zc.EncoderConfig.TimeKey = zapcore.OmitKey 22 | } 23 | logger, _ := zc.Build() 24 | return logger 25 | } 26 | -------------------------------------------------------------------------------- /apps/api/internal/logger/middleware.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "context" 5 | 6 | "contrib.rocks/apps/api/internal/config" 7 | "github.com/gin-gonic/gin" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | type contextKey int 12 | 13 | const ( 14 | loggerContextKey contextKey = iota 15 | ) 16 | 17 | // Middleware returns a gin middleware that sets the logger in the context. 18 | func Middleware(cfg *config.Config) gin.HandlerFunc { 19 | logger := buildBaseLogger(cfg) 20 | return func(c *gin.Context) { 21 | ctx := c.Request.Context() 22 | ctx = ContextWithLogger(ctx, logger) 23 | c.Request = c.Request.WithContext(ctx) 24 | c.Next() 25 | } 26 | } 27 | 28 | // ContextWithLogger returns a new context with the given logger. 29 | func ContextWithLogger(c context.Context, logger *zap.Logger) context.Context { 30 | return context.WithValue(c, loggerContextKey, logger) 31 | } 32 | 33 | // LoggerFromContext returns the logger for the given context. 34 | // The logger has been set a trace context if the request is traced. 35 | func LoggerFromContext(c context.Context) *zap.Logger { 36 | logger, ok := c.Value(loggerContextKey).(*zap.Logger) 37 | if !ok { 38 | return zap.NewNop() 39 | } 40 | return logger.WithOptions(traceContext(c)) 41 | } 42 | -------------------------------------------------------------------------------- /apps/api/internal/logger/middleware_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "contrib.rocks/apps/api/internal/config" 9 | "github.com/gin-gonic/gin" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func TestMiddleware(t *testing.T) { 14 | var logger *zap.Logger 15 | r := gin.New() 16 | r.Use(Middleware(config.NewTestConfig())) 17 | r.GET("/ping", func(c *gin.Context) { 18 | logger = LoggerFromContext(c.Request.Context()) 19 | }) 20 | 21 | w := httptest.NewRecorder() 22 | req, _ := http.NewRequest(http.MethodGet, "/ping", nil) 23 | r.ServeHTTP(w, req) 24 | 25 | if logger == nil { 26 | t.Fatalf("Expected logger to be set in context") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/api/internal/logger/trace.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "context" 5 | 6 | "contrib.rocks/apps/api/internal/tracing" 7 | "go.opentelemetry.io/otel/trace" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | const ( 12 | traceKey = "logging.googleapis.com/trace" 13 | spanKey = "logging.googleapis.com/spanId" 14 | traceSampledKey = "logging.googleapis.com/trace_sampled" 15 | ) 16 | 17 | func traceContext(c context.Context) zap.Option { 18 | traceName := tracing.TraceNameFromContext(c) 19 | spanContext := trace.SpanContextFromContext(c) 20 | if traceName == "" || !spanContext.IsValid() { 21 | return zap.Fields() 22 | } 23 | return zap.Fields( 24 | zap.String(traceKey, traceName), 25 | zap.String(spanKey, spanContext.SpanID().String()), 26 | zap.Bool(traceSampledKey, spanContext.IsSampled()), 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /apps/api/internal/server.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "contrib.rocks/apps/api/go/compress" 9 | "contrib.rocks/apps/api/go/env" 10 | "contrib.rocks/apps/api/internal/api" 11 | "contrib.rocks/apps/api/internal/config" 12 | "contrib.rocks/apps/api/internal/logger" 13 | "contrib.rocks/apps/api/internal/service" 14 | "contrib.rocks/apps/api/internal/tracing" 15 | "github.com/gin-gonic/gin" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | func StartServer() error { 20 | cfg, err := config.Load() 21 | if err != nil { 22 | return fmt.Errorf("failed to load config: %s", err.Error()) 23 | } 24 | fmt.Printf("config: %+v\n", cfg) 25 | 26 | if cfg.Env == env.EnvProduction { 27 | gin.SetMode(gin.ReleaseMode) 28 | } 29 | 30 | closeTracer := tracing.InitTraceProvider(cfg) 31 | defer closeTracer() 32 | 33 | sp := service.NewServicePack(cfg) 34 | 35 | r := gin.New() 36 | r.Use(gin.Recovery()) 37 | r.Use(config.Middleware(cfg)) 38 | r.Use(tracing.Middleware(cfg)) 39 | r.Use(logger.Middleware(cfg)) 40 | r.Use(errorHandler()) 41 | r.Use(requestLogger()) 42 | r.Use(compress.Compress()) 43 | 44 | api.Setup(r, sp) 45 | 46 | fmt.Printf("Listening on http://localhost:%s\n", cfg.Port) 47 | return r.Run(fmt.Sprintf(":%s", cfg.Port)) 48 | } 49 | 50 | func errorHandler() gin.HandlerFunc { 51 | return func(c *gin.Context) { 52 | c.Next() 53 | err := c.Errors.Last() 54 | if err == nil { 55 | return 56 | } 57 | logger.LoggerFromContext(c.Request.Context()).Error(err.Error()) 58 | c.AbortWithStatusJSON(http.StatusInternalServerError, err.JSON()) 59 | } 60 | } 61 | 62 | func requestLogger() gin.HandlerFunc { 63 | return func(c *gin.Context) { 64 | logger.LoggerFromContext(c.Request.Context()).Debug("request.start", 65 | zap.String("method", c.Request.Method), 66 | zap.String("host", c.Request.Host), 67 | zap.String("url", c.Request.URL.String()), 68 | zap.String("userAgent", c.Request.UserAgent()), 69 | zap.String("referer", c.Request.Referer()), 70 | ) 71 | // log all headers 72 | headers, _ := json.Marshal(c.Request.Header) 73 | logger.LoggerFromContext(c.Request.Context()).Debug("request.headers", zap.String("headers", string(headers))) 74 | c.Next() 75 | logger.LoggerFromContext(c.Request.Context()).Debug("request.end", 76 | zap.Int("status", c.Writer.Status()), 77 | zap.Int("size", c.Writer.Size()), 78 | ) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /apps/api/internal/service/contributors/github.go: -------------------------------------------------------------------------------- 1 | package contributors 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "fmt" 7 | "strings" 8 | 9 | "contrib.rocks/apps/api/go/model" 10 | "contrib.rocks/apps/api/internal/github/api" 11 | "contrib.rocks/apps/api/internal/tracing" 12 | "github.com/google/go-github/v69/github" 13 | "golang.org/x/sync/errgroup" 14 | ) 15 | 16 | func fetchRepositoryContributors(client *github.Client, ctx context.Context, repo *model.Repository) (*model.RepositoryContributors, error) { 17 | ctx, span := tracing.Tracer().Start(ctx, "contributors.fetchRepositoryContributors") 18 | defer span.End() 19 | 20 | eg, groupCtx := errgroup.WithContext(ctx) 21 | 22 | var repository *github.Repository 23 | var contributors []*github.Contributor 24 | 25 | // 並列でリポジトリ情報とコントリビューター情報を取得 26 | eg.Go(func() error { 27 | var err error 28 | repository, err = fetchRepository(client, groupCtx, repo) 29 | return err 30 | }) 31 | 32 | eg.Go(func() error { 33 | var err error 34 | contributors, err = fetchAllContributors(client, groupCtx, repo) 35 | return err 36 | }) 37 | 38 | if err := eg.Wait(); err != nil { 39 | return nil, err 40 | } 41 | 42 | return buildRepositoryContributors(repository, contributors), nil 43 | } 44 | 45 | // リポジトリ情報を取得 46 | func fetchRepository(client *github.Client, ctx context.Context, repo *model.Repository) (*github.Repository, error) { 47 | errorHandler := func(err error, resp *github.Response) error { 48 | return api.HandleRepositoryNotFoundError(err, resp, repo) 49 | } 50 | 51 | repository, _, err := api.Call(ctx, func() (*github.Repository, *github.Response, error) { 52 | return client.Repositories.Get(ctx, repo.Owner, repo.RepoName) 53 | }, errorHandler) 54 | 55 | return repository, err 56 | } 57 | 58 | // すべてのコントリビューター情報をページングしながら取得 59 | func fetchAllContributors(client *github.Client, ctx context.Context, repo *model.Repository) ([]*github.Contributor, error) { 60 | var allContributors []*github.Contributor 61 | options := &github.ListContributorsOptions{ 62 | Anon: "true", 63 | ListOptions: github.ListOptions{PerPage: 100}, 64 | } 65 | 66 | for { 67 | errorHandler := func(err error, resp *github.Response) error { 68 | return api.HandleRepositoryNotFoundError(err, resp, repo) 69 | } 70 | 71 | contributors, resp, err := api.Call(ctx, func() ([]*github.Contributor, *github.Response, error) { 72 | return client.Repositories.ListContributors(ctx, repo.Owner, repo.RepoName, options) 73 | }, errorHandler) 74 | 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | allContributors = append(allContributors, contributors...) 80 | 81 | // レスポンスからページング情報を取得 82 | if resp == nil || resp.NextPage == 0 { 83 | break 84 | } 85 | options.Page = resp.NextPage 86 | } 87 | 88 | return allContributors, nil 89 | } 90 | 91 | func buildRepositoryContributors(rawRepository *github.Repository, rawContributors []*github.Contributor) *model.RepositoryContributors { 92 | contributors := make([]*model.Contributor, 0, len(rawContributors)) 93 | for _, item := range rawContributors { 94 | switch strings.ToLower(item.GetType()) { 95 | case "bot": 96 | continue 97 | case "anonymous": 98 | contributors = append(contributors, &model.Contributor{ 99 | Login: item.GetName(), 100 | AvatarURL: fmt.Sprintf("https://www.gravatar.com/avatar/%x?d=mp", md5.Sum([]byte(item.GetEmail()))), 101 | Contributions: item.GetContributions(), 102 | }) 103 | continue 104 | default: 105 | contributors = append(contributors, &model.Contributor{ 106 | ID: item.GetID(), 107 | Login: item.GetLogin(), 108 | AvatarURL: item.GetAvatarURL(), 109 | HTMLURL: item.GetHTMLURL(), 110 | Contributions: item.GetContributions(), 111 | }) 112 | } 113 | } 114 | return &model.RepositoryContributors{ 115 | Repository: &model.Repository{ 116 | Owner: rawRepository.Owner.GetLogin(), 117 | RepoName: rawRepository.GetName(), 118 | }, 119 | StargazersCount: rawRepository.GetStargazersCount(), 120 | Contributors: contributors, 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /apps/api/internal/service/contributors/github_test.go: -------------------------------------------------------------------------------- 1 | package contributors 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "strings" 11 | "testing" 12 | 13 | "contrib.rocks/apps/api/go/model" 14 | "contrib.rocks/apps/api/internal/github/api" 15 | "github.com/avast/retry-go/v4" 16 | "github.com/google/go-github/v69/github" 17 | ) 18 | 19 | func unwrapError(err error) error { 20 | if err == nil { 21 | return nil 22 | } 23 | 24 | if unwrapped := errors.Unwrap(err); unwrapped != nil { 25 | return unwrapped 26 | } 27 | 28 | return err 29 | } 30 | 31 | func setup(t *testing.T, handler http.Handler) (*github.Client, *httptest.Server) { 32 | server := httptest.NewServer(handler) 33 | t.Cleanup(func() { 34 | server.Close() 35 | }) 36 | ghclient := github.NewClient(server.Client()) 37 | ghclient.BaseURL, _ = url.Parse(server.URL + "/") 38 | return ghclient, server 39 | } 40 | 41 | func Test_fetchRepositoryContributors(t *testing.T) { 42 | t.Run("should fetch repository and contributors data", func(t *testing.T) { 43 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | if r.URL.Path == "/repos/foo/bar" { 45 | w.WriteHeader(http.StatusOK) 46 | w.Header().Set("Content-Type", "application/json") 47 | w.Write([]byte(`{"id": 1, "name": "bar", "owner": {"login": "foo"}, "stargazers_count": 100}`)) 48 | return 49 | } 50 | 51 | if r.URL.Path == "/repos/foo/bar/contributors" { 52 | w.WriteHeader(http.StatusOK) 53 | w.Header().Set("Content-Type", "application/json") 54 | w.Write([]byte(`[{"id": 1, "login": "user1"}, {"id": 2, "login": "user2"}]`)) 55 | return 56 | } 57 | 58 | t.Fatalf("unexpected request to %s", r.URL.Path) 59 | }) 60 | 61 | ghclient, _ := setup(t, handler) 62 | repository := &model.Repository{Owner: "foo", RepoName: "bar"} 63 | 64 | result, err := fetchRepositoryContributors(ghclient, context.Background(), repository) 65 | if err != nil { 66 | t.Fatalf("unexpected error: %v", err) 67 | } 68 | 69 | // Verify repository data 70 | if result.Owner != "foo" { 71 | t.Errorf("expected owner to be foo, got %s", result.Owner) 72 | } 73 | if result.RepoName != "bar" { 74 | t.Errorf("expected repo name to be bar, got %s", result.RepoName) 75 | } 76 | if result.StargazersCount != 100 { 77 | t.Errorf("expected stargazers to be 100, got %d", result.StargazersCount) 78 | } 79 | 80 | // Verify contributors data 81 | if len(result.Contributors) != 2 { 82 | t.Fatalf("expected 2 contributors, got %d", len(result.Contributors)) 83 | } 84 | if result.Contributors[0].ID != 1 { 85 | t.Errorf("expected contributor ID to be 1, got %d", result.Contributors[0].ID) 86 | } 87 | if result.Contributors[1].ID != 2 { 88 | t.Errorf("expected contributor ID to be 2, got %d", result.Contributors[1].ID) 89 | } 90 | }) 91 | 92 | t.Run("should handle not found error", func(t *testing.T) { 93 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 94 | w.WriteHeader(http.StatusNotFound) 95 | }) 96 | 97 | ghclient, _ := setup(t, handler) 98 | repository := &model.Repository{Owner: "foo", RepoName: "bar"} 99 | 100 | _, err := fetchRepositoryContributors(ghclient, context.Background(), repository) 101 | if err == nil { 102 | t.Fatal("expected error, got nil") 103 | } 104 | 105 | err = unwrapError(err) 106 | var repoNotFoundErr *model.RepositoryNotFoundError 107 | if !errors.As(err, &repoNotFoundErr) { 108 | t.Fatalf("expected RepositoryNotFoundError, got %T: %v", err, err) 109 | } 110 | }) 111 | } 112 | 113 | func Test_buildRepositoryContributors(t *testing.T) { 114 | t.Run(".Owner should equal to repo.owner.login", func(t *testing.T) { 115 | rawRepo := &github.Repository{ 116 | Owner: &github.User{Login: github.String("foo")}, 117 | } 118 | rawContribs := []*github.Contributor{} 119 | got := buildRepositoryContributors(rawRepo, rawContribs) 120 | if got.Owner != "foo" { 121 | t.Fatalf("expected owner to be foo, got %s", got.Owner) 122 | } 123 | }) 124 | t.Run(".RepoName should equal to repo.name", func(t *testing.T) { 125 | rawRepo := &github.Repository{Name: github.String("bar")} 126 | rawContribs := []*github.Contributor{} 127 | got := buildRepositoryContributors(rawRepo, rawContribs) 128 | if got.RepoName != "bar" { 129 | t.Fatalf("expected repo name to be bar, got %s", got.RepoName) 130 | } 131 | }) 132 | t.Run(".Stargazors should equal to repo.stargazers_count", func(t *testing.T) { 133 | rawRepo := &github.Repository{StargazersCount: github.Int(1)} 134 | rawContribs := []*github.Contributor{} 135 | got := buildRepositoryContributors(rawRepo, rawContribs) 136 | if got.StargazersCount != 1 { 137 | t.Fatalf("expected stargazers to be 1, got %d", got.StargazersCount) 138 | } 139 | }) 140 | t.Run(".Contributors should equal to contributors", func(t *testing.T) { 141 | rawRepo := &github.Repository{} 142 | rawContribs := []*github.Contributor{ 143 | {ID: github.Int64(1)}, 144 | {ID: github.Int64(2)}, 145 | } 146 | got := buildRepositoryContributors(rawRepo, rawContribs) 147 | if len(got.Contributors) != 2 { 148 | t.Fatalf("expected 2 contributors, got %d", len(got.Contributors)) 149 | } 150 | if got.Contributors[0].ID != 1 { 151 | t.Fatalf("expected contributor id to be 1, got %d", got.Contributors[0].ID) 152 | } 153 | if got.Contributors[1].ID != 2 { 154 | t.Fatalf("expected contributor id to be 2, got %d", got.Contributors[1].ID) 155 | } 156 | }) 157 | t.Run(".Contributors should ignore bot users", func(t *testing.T) { 158 | rawRepo := &github.Repository{} 159 | rawContribs := []*github.Contributor{ 160 | {ID: github.Int64(1)}, 161 | {ID: github.Int64(2)}, 162 | {ID: github.Int64(3), Type: github.String("Bot")}, 163 | } 164 | got := buildRepositoryContributors(rawRepo, rawContribs) 165 | if len(got.Contributors) != 2 { 166 | t.Fatalf("expected 2 contributors, got %d", len(got.Contributors)) 167 | } 168 | if got.Contributors[0].ID != 1 { 169 | t.Fatalf("expected contributor id to be 1, got %d", got.Contributors[0].ID) 170 | } 171 | if got.Contributors[1].ID != 2 { 172 | t.Fatalf("expected contributor id to be 2, got %d", got.Contributors[1].ID) 173 | } 174 | }) 175 | t.Run(".Contributors should normarize anonymous users", func(t *testing.T) { 176 | rawRepo := &github.Repository{} 177 | rawContribs := []*github.Contributor{ 178 | {Type: github.String("Anonymous"), Name: github.String("foo"), Email: github.String("foo@example.com")}, 179 | } 180 | got := buildRepositoryContributors(rawRepo, rawContribs) 181 | if len(got.Contributors) != 1 { 182 | t.Fatalf("expected 1 contributor, got %d", len(got.Contributors)) 183 | } 184 | if got.Contributors[0].Login != "foo" { 185 | t.Fatalf("unexpected contributor login, got %s", got.Contributors[0].Login) 186 | } 187 | if !strings.HasPrefix(got.Contributors[0].AvatarURL, "https://www.gravatar.com/avatar/") { 188 | t.Fatalf("unexpected contributor avatar url, got %s", got.Contributors[0].AvatarURL) 189 | } 190 | }) 191 | } 192 | 193 | // テスト用にローカル版のエラー型関数を定義 194 | func getGitHubRetryOptions(ctx context.Context, repo *model.Repository) []retry.Option { 195 | return api.GetRetryOptions(ctx) 196 | } 197 | 198 | func Test_getGitHubRetryOptions(t *testing.T) { 199 | repo := &model.Repository{Owner: "test", RepoName: "test-repo"} 200 | ctx := context.Background() 201 | options := getGitHubRetryOptions(ctx, repo) 202 | 203 | // 具体的な数値のチェックを避け、単にオプションが返されることを確認 204 | if len(options) == 0 { 205 | t.Fatalf("expected retry options, got empty slice") 206 | } 207 | } 208 | 209 | func Test_getGitHubErrorType(t *testing.T) { 210 | tests := []struct { 211 | name string 212 | err error 213 | resp *github.Response 214 | expected api.ErrorType 215 | }{ 216 | { 217 | name: "nil error and nil response", 218 | err: nil, 219 | resp: nil, 220 | expected: api.ErrorTypeUnknown, 221 | }, 222 | { 223 | name: "nil error with NotFound response", 224 | err: nil, 225 | resp: &github.Response{Response: &http.Response{StatusCode: http.StatusNotFound}}, 226 | expected: api.ErrorTypeNotFound, 227 | }, 228 | { 229 | name: "timeout error", 230 | err: &net.DNSError{IsTimeout: true}, 231 | resp: nil, 232 | expected: api.ErrorTypeTimeout, 233 | }, 234 | { 235 | name: "server error (500)", 236 | err: &github.ErrorResponse{Response: &http.Response{StatusCode: 500}}, 237 | resp: nil, 238 | expected: api.ErrorTypeServer, 239 | }, 240 | { 241 | name: "server error (503)", 242 | err: &github.ErrorResponse{Response: &http.Response{StatusCode: 503}}, 243 | resp: nil, 244 | expected: api.ErrorTypeServer, 245 | }, 246 | { 247 | name: "not found error via ErrorResponse", 248 | err: &github.ErrorResponse{Response: &http.Response{StatusCode: 404}}, 249 | resp: nil, 250 | expected: api.ErrorTypeNotFound, 251 | }, 252 | { 253 | name: "rate limit error", 254 | err: &github.RateLimitError{}, 255 | resp: nil, 256 | expected: api.ErrorTypeRateLimit, 257 | }, 258 | { 259 | name: "abuse rate limit error", 260 | err: &github.AbuseRateLimitError{}, 261 | resp: nil, 262 | expected: api.ErrorTypeAbuseRateLimit, 263 | }, 264 | { 265 | name: "connection refused error via OpError", 266 | err: &net.OpError{Op: "dial", Err: errors.New("connection refused")}, 267 | resp: nil, 268 | expected: api.ErrorTypeConnectionRefused, 269 | }, 270 | { 271 | name: "connection refused error via url.Error", 272 | err: &url.Error{Op: "get", URL: "http://example.com", Err: errors.New("connection refused")}, 273 | resp: nil, 274 | expected: api.ErrorTypeConnectionRefused, 275 | }, 276 | { 277 | name: "unauthorized error (401)", 278 | err: &github.ErrorResponse{Response: &http.Response{StatusCode: 401}}, 279 | resp: nil, 280 | expected: api.ErrorTypeUnauthorized, 281 | }, 282 | { 283 | name: "forbidden error (403)", 284 | err: &github.ErrorResponse{Response: &http.Response{StatusCode: 403}}, 285 | resp: nil, 286 | expected: api.ErrorTypeForbidden, 287 | }, 288 | { 289 | name: "client error (422)", 290 | err: &github.ErrorResponse{Response: &http.Response{StatusCode: 422}}, 291 | resp: nil, 292 | expected: api.ErrorTypeClientError, 293 | }, 294 | { 295 | name: "other error", 296 | err: errors.New("some other error"), 297 | resp: nil, 298 | expected: api.ErrorTypeUnknown, 299 | }, 300 | } 301 | 302 | for _, tt := range tests { 303 | t.Run(tt.name, func(t *testing.T) { 304 | got := api.GetErrorType(tt.err, tt.resp) 305 | if got != tt.expected { 306 | t.Errorf("GetErrorType() = %v, want %v", got, tt.expected) 307 | } 308 | }) 309 | } 310 | } 311 | 312 | func Test_isRetryableError(t *testing.T) { 313 | tests := []struct { 314 | name string 315 | err error 316 | expected bool 317 | }{ 318 | { 319 | name: "nil error", 320 | err: nil, 321 | expected: false, 322 | }, 323 | { 324 | name: "timeout error", 325 | err: &net.DNSError{IsTimeout: true}, 326 | expected: true, 327 | }, 328 | { 329 | name: "server error", 330 | err: &github.ErrorResponse{Response: &http.Response{StatusCode: 500}}, 331 | expected: true, 332 | }, 333 | { 334 | name: "connection refused error", 335 | err: &net.OpError{Op: "dial", Err: errors.New("connection refused")}, 336 | expected: true, 337 | }, 338 | { 339 | name: "rate limit error", 340 | err: &github.RateLimitError{}, 341 | expected: true, 342 | }, 343 | { 344 | name: "abuse rate limit error", 345 | err: &github.AbuseRateLimitError{}, 346 | expected: true, 347 | }, 348 | { 349 | name: "not found error", 350 | err: &github.ErrorResponse{Response: &http.Response{StatusCode: 404}}, 351 | expected: false, 352 | }, 353 | { 354 | name: "other error", 355 | err: errors.New("some other error"), 356 | expected: false, 357 | }, 358 | } 359 | 360 | for _, tt := range tests { 361 | t.Run(tt.name, func(t *testing.T) { 362 | got := api.IsRetryableError(tt.err) 363 | if got != tt.expected { 364 | t.Errorf("IsRetryableError() = %v, want %v", got, tt.expected) 365 | } 366 | }) 367 | } 368 | } 369 | 370 | func Test_isNotFoundError(t *testing.T) { 371 | tests := []struct { 372 | name string 373 | err error 374 | resp *github.Response 375 | expected bool 376 | }{ 377 | { 378 | name: "nil error and nil response", 379 | err: nil, 380 | resp: nil, 381 | expected: false, 382 | }, 383 | { 384 | name: "nil error with NotFound response", 385 | err: nil, 386 | resp: &github.Response{Response: &http.Response{StatusCode: http.StatusNotFound}}, 387 | expected: true, 388 | }, 389 | { 390 | name: "not found error via ErrorResponse", 391 | err: &github.ErrorResponse{Response: &http.Response{StatusCode: 404}}, 392 | resp: nil, 393 | expected: true, 394 | }, 395 | { 396 | name: "other error", 397 | err: errors.New("some other error"), 398 | resp: nil, 399 | expected: false, 400 | }, 401 | } 402 | 403 | for _, tt := range tests { 404 | t.Run(tt.name, func(t *testing.T) { 405 | got := api.IsNotFoundError(tt.err, tt.resp) 406 | if got != tt.expected { 407 | t.Errorf("IsNotFoundError() = %v, want %v", got, tt.expected) 408 | } 409 | }) 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /apps/api/internal/service/contributors/service.go: -------------------------------------------------------------------------------- 1 | package contributors 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "contrib.rocks/apps/api/go/model" 8 | "contrib.rocks/apps/api/internal/logger" 9 | "contrib.rocks/apps/api/internal/service/internal/appcache" 10 | "contrib.rocks/apps/api/internal/service/internal/cachekey" 11 | "contrib.rocks/apps/api/internal/service/internal/cacheutil" 12 | "contrib.rocks/apps/api/internal/tracing" 13 | "github.com/google/go-github/v69/github" 14 | ) 15 | 16 | type GitHubClientProvider interface { 17 | Get() *github.Client 18 | } 19 | 20 | type Service struct { 21 | ghProvider GitHubClientProvider 22 | cache appcache.AppCache 23 | } 24 | 25 | func New(ghProvider GitHubClientProvider, cache appcache.AppCache) *Service { 26 | return &Service{ghProvider, cache} 27 | } 28 | 29 | func (s *Service) GetContributors(c context.Context, r *model.Repository) (*model.RepositoryContributors, error) { 30 | ctx, span := tracing.Tracer().Start(c, "contributors.Service.GetContributors") 31 | defer span.End() 32 | log := logger.LoggerFromContext(ctx) 33 | 34 | cacheKey := cachekey.ForContributors(r, "json") 35 | // restore cache 36 | var cache *model.RepositoryContributors 37 | err := s.cache.GetJSON(ctx, cacheKey, &cache) 38 | if err != nil { 39 | return nil, err 40 | } 41 | if cache != nil { 42 | log.Debug(fmt.Sprintf("restored contributors-json from cache: %s", cacheKey)) 43 | return cache, nil 44 | } 45 | cacheutil.LogCacheMiss(ctx, "contributors-json", cacheKey) 46 | 47 | // get contributors from github 48 | gh := s.ghProvider.Get() 49 | data, err := fetchRepositoryContributors(gh, ctx, r) 50 | if err != nil { 51 | return nil, err 52 | } 53 | // save cache 54 | err = s.cache.SaveJSON(ctx, cacheKey, data) 55 | if err != nil { 56 | return nil, err 57 | } 58 | return data, nil 59 | } 60 | -------------------------------------------------------------------------------- /apps/api/internal/service/image/options.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import "contrib.rocks/apps/api/go/renderer" 4 | 5 | const ( 6 | defaultMaxCount = 100 7 | defaultColumns = 12 8 | defaultItemSize = 64 9 | ) 10 | 11 | func normalizeRendererOptions(options *renderer.RendererOptions) *renderer.RendererOptions { 12 | if options.MaxCount < 1 { 13 | options.MaxCount = defaultMaxCount 14 | } 15 | if options.Columns < 1 { 16 | options.Columns = defaultColumns 17 | } 18 | options.ItemSize = defaultItemSize 19 | return options 20 | } 21 | -------------------------------------------------------------------------------- /apps/api/internal/service/image/service.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "contrib.rocks/apps/api/go/dataurl" 8 | "contrib.rocks/apps/api/go/model" 9 | "contrib.rocks/apps/api/go/renderer" 10 | "contrib.rocks/apps/api/go/util" 11 | "contrib.rocks/apps/api/internal/logger" 12 | "contrib.rocks/apps/api/internal/service/internal/appcache" 13 | "contrib.rocks/apps/api/internal/service/internal/cachekey" 14 | "contrib.rocks/apps/api/internal/service/internal/cacheutil" 15 | "contrib.rocks/apps/api/internal/tracing" 16 | "golang.org/x/sync/errgroup" 17 | ) 18 | 19 | // DataURLConverterFunc defines the function signature for converting URLs to data URLs 20 | type DataURLConverterFunc func(ctx context.Context, remoteURL string, extraParams map[string]string) (string, error) 21 | 22 | // Default implementation uses the dataurl package 23 | var dataURLConverter DataURLConverterFunc = dataurl.Convert 24 | 25 | func New(cache appcache.AppCache) *Service { 26 | return &Service{cache} 27 | } 28 | 29 | type Service struct { 30 | cache appcache.AppCache 31 | } 32 | 33 | func (s *Service) GetImage(c context.Context, repo *model.Repository, options *renderer.RendererOptions, includeAnonymous bool) (model.FileHandle, error) { 34 | ctx, span := tracing.Tracer().Start(c, "image.Service.GetImage") 35 | defer span.End() 36 | log := logger.LoggerFromContext(ctx) 37 | 38 | options = normalizeRendererOptions(options) 39 | cacheKey := cachekey.ForImage(repo, options, "svg", includeAnonymous) 40 | 41 | cache, err := s.cache.Get(ctx, cacheKey) 42 | if err != nil { 43 | return nil, err 44 | } 45 | if cache == nil { 46 | cacheutil.LogCacheMiss(ctx, "image", cacheKey) 47 | return nil, nil 48 | } 49 | log.Debug(fmt.Sprintf("restored image from cache: %s", cacheKey)) 50 | return cache, nil 51 | } 52 | 53 | func (s *Service) RenderImage(c context.Context, data *model.RepositoryContributors, options *renderer.RendererOptions, includeAnonymous bool) (model.FileHandle, error) { 54 | ctx, span := tracing.Tracer().Start(c, "image.Service.RenderImage") 55 | defer span.End() 56 | 57 | options = normalizeRendererOptions(options) 58 | cacheKey := cachekey.ForImage(data.Repository, options, "svg", includeAnonymous) 59 | 60 | data, err := s.normalizeContributors(ctx, data, options, includeAnonymous) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | image := renderer.NewRenderer(options).Render(data) 66 | 67 | err = s.cache.Save(c, cacheKey, image.Bytes(), image.ContentType()) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return image, nil 72 | } 73 | 74 | func (s *Service) normalizeContributors(ctx context.Context, base *model.RepositoryContributors, options *renderer.RendererOptions, includeAnonymous bool) (*model.RepositoryContributors, error) { 75 | // フィルタリングと制限 76 | filteredContributors := make([]*model.Contributor, 0, len(base.Contributors)) 77 | for _, c := range base.Contributors { 78 | if includeAnonymous || c.ID != 0 { 79 | filteredContributors = append(filteredContributors, c) 80 | } 81 | } 82 | 83 | maxCount := util.Min(options.MaxCount, len(filteredContributors)) 84 | result := &model.RepositoryContributors{ 85 | Repository: base.Repository, 86 | StargazersCount: base.StargazersCount, 87 | Contributors: make([]*model.Contributor, maxCount), 88 | } 89 | copy(result.Contributors, filteredContributors[:maxCount]) 90 | 91 | // サイズパラメータの共通設定 92 | sizeParams := map[string]string{ 93 | "size": fmt.Sprint(options.ItemSize), 94 | "s": fmt.Sprint(options.ItemSize), 95 | } 96 | 97 | // データURL変換の並列処理 98 | eg, ctx := errgroup.WithContext(ctx) 99 | for i := range result.Contributors { 100 | i := i // ループ変数をキャプチャ 101 | eg.Go(func() error { 102 | contributor := result.Contributors[i] 103 | dataURL, err := dataURLConverter(ctx, contributor.AvatarURL, sizeParams) 104 | if err != nil { 105 | return fmt.Errorf("avatar URL conversion failed for contributor %s: %w", 106 | contributor.Login, err) 107 | } 108 | result.Contributors[i].AvatarURL = dataURL 109 | return nil 110 | }) 111 | } 112 | 113 | return result, eg.Wait() 114 | } 115 | -------------------------------------------------------------------------------- /apps/api/internal/service/image/service_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "contrib.rocks/apps/api/go/model" 8 | "contrib.rocks/apps/api/go/renderer" 9 | ) 10 | 11 | // mockCache is a simple implementation of appcache.AppCache for testing 12 | type mockCache struct { 13 | getFunc func(context.Context, string) (model.FileHandle, error) 14 | getJSONFunc func(context.Context, string, any) error 15 | saveFunc func(context.Context, string, []byte, string) error 16 | saveJSONFunc func(context.Context, string, any) error 17 | } 18 | 19 | func (m *mockCache) Get(ctx context.Context, key string) (model.FileHandle, error) { 20 | if m.getFunc != nil { 21 | return m.getFunc(ctx, key) 22 | } 23 | return nil, nil 24 | } 25 | 26 | func (m *mockCache) GetJSON(ctx context.Context, key string, v any) error { 27 | if m.getJSONFunc != nil { 28 | return m.getJSONFunc(ctx, key, v) 29 | } 30 | return nil 31 | } 32 | 33 | func (m *mockCache) Save(ctx context.Context, key string, data []byte, contentType string) error { 34 | if m.saveFunc != nil { 35 | return m.saveFunc(ctx, key, data, contentType) 36 | } 37 | return nil 38 | } 39 | 40 | func (m *mockCache) SaveJSON(ctx context.Context, key string, v any) error { 41 | if m.saveJSONFunc != nil { 42 | return m.saveJSONFunc(ctx, key, v) 43 | } 44 | return nil 45 | } 46 | 47 | // mockDataURLConverter is a helper function for testing that avoids making real HTTP requests 48 | func mockDataURLConverter(_ context.Context, avatarURL string, _ map[string]string) (string, error) { 49 | return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", nil 50 | } 51 | 52 | func TestService_normalizeContributors(t *testing.T) { 53 | // Store the original converter to restore it after the test 54 | originalConverter := dataURLConverter 55 | // Set the mock converter for testing 56 | dataURLConverter = mockDataURLConverter 57 | // Restore the original converter after the test 58 | defer func() { dataURLConverter = originalConverter }() 59 | 60 | service := &Service{ 61 | cache: &mockCache{}, 62 | } 63 | 64 | repo := &model.Repository{ 65 | Owner: "test-owner", 66 | RepoName: "test-repo", 67 | } 68 | 69 | contributors := []*model.Contributor{ 70 | {ID: 1, Login: "user1", AvatarURL: "https://avatar1.com"}, 71 | {ID: 2, Login: "user2", AvatarURL: "https://avatar2.com"}, 72 | {ID: 0, Login: "anonymous", AvatarURL: "https://avatar3.com"}, // Anonymous user 73 | } 74 | 75 | input := &model.RepositoryContributors{ 76 | Repository: repo, 77 | StargazersCount: 100, 78 | Contributors: contributors, 79 | } 80 | 81 | options := &renderer.RendererOptions{ 82 | MaxCount: 2, 83 | Columns: 4, 84 | ItemSize: 64, 85 | } 86 | 87 | t.Run("with includeAnonymous=true", func(t *testing.T) { 88 | result, err := service.normalizeContributors(context.Background(), input, options, true) 89 | if err != nil { 90 | t.Fatalf("Expected no error, got %v", err) 91 | } 92 | 93 | if len(result.Contributors) != 2 { 94 | t.Errorf("Expected 2 contributors, got %d", len(result.Contributors)) 95 | } 96 | 97 | if result.Contributors[0].ID != 1 { 98 | t.Errorf("Expected first contributor ID to be 1, got %d", result.Contributors[0].ID) 99 | } 100 | 101 | // Verify data URL conversion was applied 102 | if result.Contributors[0].AvatarURL == "https://avatar1.com" { 103 | t.Errorf("Avatar URL should have been converted to a data URL") 104 | } 105 | }) 106 | 107 | t.Run("with includeAnonymous=false", func(t *testing.T) { 108 | result, err := service.normalizeContributors(context.Background(), input, options, false) 109 | if err != nil { 110 | t.Fatalf("Expected no error, got %v", err) 111 | } 112 | 113 | if len(result.Contributors) != 2 { 114 | t.Errorf("Expected 2 contributors, got %d", len(result.Contributors)) 115 | } 116 | 117 | // Check that anonymous user was filtered out 118 | for _, c := range result.Contributors { 119 | if c.ID == 0 { 120 | t.Errorf("Anonymous contributor should have been filtered out") 121 | } 122 | } 123 | }) 124 | } 125 | -------------------------------------------------------------------------------- /apps/api/internal/service/internal/appcache/appcache.go: -------------------------------------------------------------------------------- 1 | package appcache 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/storage" 7 | "contrib.rocks/apps/api/go/model" 8 | ) 9 | 10 | type AppCache interface { 11 | Get(c context.Context, name string) (model.FileHandle, error) 12 | GetJSON(c context.Context, name string, v any) error 13 | Save(c context.Context, name string, data []byte, contentType string) error 14 | SaveJSON(c context.Context, name string, v any) error 15 | } 16 | 17 | func NewGCSCache(storageClient *storage.Client, bucketName string) AppCache { 18 | return newGCSCache(storageClient, bucketName) 19 | } 20 | 21 | func NewMemoryCache() AppCache { 22 | return newMemoryCache() 23 | } 24 | -------------------------------------------------------------------------------- /apps/api/internal/service/internal/appcache/gcs.go: -------------------------------------------------------------------------------- 1 | package appcache 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | 9 | "cloud.google.com/go/storage" 10 | "contrib.rocks/apps/api/go/model" 11 | "contrib.rocks/apps/api/internal/tracing" 12 | "go.opentelemetry.io/otel/attribute" 13 | "golang.org/x/sync/errgroup" 14 | ) 15 | 16 | var _ AppCache = &gcsCache{} 17 | 18 | type gcsCache struct { 19 | bucket *storage.BucketHandle 20 | } 21 | 22 | func newGCSCache(storageClient *storage.Client, bucketName string) *gcsCache { 23 | return &gcsCache{ 24 | bucket: storageClient.Bucket(bucketName), 25 | } 26 | } 27 | 28 | func (s *gcsCache) Get(c context.Context, name string) (model.FileHandle, error) { 29 | ctx, span := tracing.Tracer().Start(c, "appcache.Get") 30 | defer span.End() 31 | 32 | return getFile(s.bucket, ctx, name) 33 | } 34 | 35 | func (s *gcsCache) GetJSON(c context.Context, name string, v any) error { 36 | ctx, span := tracing.Tracer().Start(c, "appcache.GetJSON") 37 | defer span.End() 38 | 39 | o, err := getFile(s.bucket, ctx, name) 40 | if err != nil { 41 | return err 42 | } 43 | if o == nil { 44 | v = nil 45 | return nil 46 | } 47 | r := o.Reader() 48 | defer r.Close() 49 | return json.NewDecoder(r).Decode(&v) 50 | } 51 | 52 | func (s *gcsCache) Save(c context.Context, name string, data []byte, contentType string) error { 53 | ctx, span := tracing.Tracer().Start(c, "appcache.Save") 54 | defer span.End() 55 | return saveFile(s.bucket, ctx, name, data, contentType) 56 | } 57 | 58 | func (s *gcsCache) SaveJSON(c context.Context, name string, v any) error { 59 | ctx, span := tracing.Tracer().Start(c, "appcache.SaveJSON") 60 | defer span.End() 61 | 62 | data, err := json.Marshal(v) 63 | if err != nil { 64 | return err 65 | } 66 | return saveFile(s.bucket, ctx, name, data, "application/json") 67 | } 68 | 69 | func getFile(bucket *storage.BucketHandle, c context.Context, name string) (model.FileHandle, error) { 70 | if bucket == nil { 71 | return nil, nil 72 | } 73 | ctx, span := tracing.Tracer().Start(c, "appcache.getFile") 74 | defer span.End() 75 | span.SetAttributes(attribute.String("cache.object.name", name)) 76 | 77 | obj := bucket.Object(name) 78 | file := &gcsFileHandle{} 79 | 80 | // Using Background context to prevent cancellation during fetching operations 81 | bgCtx := context.Background() 82 | eg, _ := errgroup.WithContext(bgCtx) 83 | 84 | eg.Go(func() error { 85 | attrs, err := obj.Attrs(ctx) 86 | if err != nil { 87 | return err 88 | } 89 | file.attrs = attrs 90 | return nil 91 | }) 92 | 93 | eg.Go(func() error { 94 | r, err := obj.NewReader(ctx) 95 | if err != nil { 96 | return err 97 | } 98 | file.reader = r 99 | return nil 100 | }) 101 | 102 | if err := eg.Wait(); err != nil { 103 | if file.reader != nil { 104 | file.reader.Close() 105 | } 106 | if err == storage.ErrObjectNotExist { 107 | return nil, nil 108 | } 109 | return nil, err 110 | } 111 | 112 | return file, nil 113 | } 114 | 115 | func saveFile(bucket *storage.BucketHandle, c context.Context, name string, data []byte, contentType string) error { 116 | if bucket == nil { 117 | return nil 118 | } 119 | ctx, span := tracing.Tracer().Start(c, "appcache.saveFile") 120 | defer span.End() 121 | span.SetAttributes(attribute.String("cache.object.name", name)) 122 | 123 | w := bucket.Object(name).NewWriter(ctx) 124 | defer w.Close() 125 | w.ContentType = contentType 126 | _, err := w.Write(data) 127 | return err 128 | } 129 | 130 | var _ model.FileHandle = &gcsFileHandle{} 131 | 132 | type gcsFileHandle struct { 133 | reader *storage.Reader 134 | attrs *storage.ObjectAttrs 135 | } 136 | 137 | func (h *gcsFileHandle) Reader() io.ReadCloser { 138 | return h.reader 139 | } 140 | func (h *gcsFileHandle) Size() int64 { 141 | return h.attrs.Size 142 | } 143 | func (h *gcsFileHandle) ContentType() string { 144 | return h.attrs.ContentType 145 | } 146 | func (h *gcsFileHandle) ETag() string { 147 | return fmt.Sprintf("%x", h.attrs.MD5) 148 | } 149 | -------------------------------------------------------------------------------- /apps/api/internal/service/internal/appcache/memory.go: -------------------------------------------------------------------------------- 1 | package appcache 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/md5" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | 11 | "contrib.rocks/apps/api/go/model" 12 | ) 13 | 14 | var _ AppCache = &memoryCache{} 15 | 16 | // memoryCache is a simple in-memory cache. (for local debug or testing) 17 | type memoryCache struct { 18 | fileCache map[string]model.FileHandle 19 | jsonCache map[string][]byte 20 | } 21 | 22 | func newMemoryCache() *memoryCache { 23 | return &memoryCache{ 24 | fileCache: make(map[string]model.FileHandle), 25 | jsonCache: make(map[string][]byte), 26 | } 27 | } 28 | 29 | func (cache *memoryCache) Get(c context.Context, name string) (model.FileHandle, error) { 30 | return cache.fileCache[name], nil 31 | } 32 | func (cache *memoryCache) GetJSON(c context.Context, name string, v any) error { 33 | cached := cache.jsonCache[name] 34 | if cached == nil { 35 | return nil 36 | } 37 | return json.Unmarshal(cached, &v) 38 | } 39 | func (cache *memoryCache) Save(c context.Context, name string, data []byte, contentType string) error { 40 | cache.fileCache[name] = &memoryFileHandle{data, contentType} 41 | return nil 42 | } 43 | func (cache *memoryCache) SaveJSON(c context.Context, name string, v any) error { 44 | data, err := json.Marshal(v) 45 | if err != nil { 46 | return err 47 | } 48 | cache.jsonCache[name] = data 49 | return nil 50 | } 51 | 52 | var _ model.FileHandle = &memoryFileHandle{} 53 | 54 | type memoryFileHandle struct { 55 | data []byte 56 | contentType string 57 | } 58 | 59 | func (f *memoryFileHandle) ContentType() string { 60 | return f.contentType 61 | } 62 | 63 | func (f *memoryFileHandle) ETag() string { 64 | return fmt.Sprintf("%x", md5.Sum(f.data)) 65 | } 66 | 67 | func (f *memoryFileHandle) Reader() io.ReadCloser { 68 | return io.NopCloser(bytes.NewBuffer(f.data)) 69 | } 70 | 71 | func (f *memoryFileHandle) Size() int64 { 72 | return int64(len(f.data)) 73 | } 74 | -------------------------------------------------------------------------------- /apps/api/internal/service/internal/cachekey/keys.go: -------------------------------------------------------------------------------- 1 | // Package cachekey provides standardized cache key generation for the application 2 | package cachekey 3 | 4 | import ( 5 | "fmt" 6 | 7 | "contrib.rocks/apps/api/go/model" 8 | "contrib.rocks/apps/api/go/renderer" 9 | ) 10 | 11 | // ForRepository generates a cache key for repository data 12 | // Format: repo/{owner}--{repo}.{ext} 13 | func ForRepository(r *model.Repository, ext string) string { 14 | return fmt.Sprintf("repo/%s--%s.%s", r.Owner, r.RepoName, ext) 15 | } 16 | 17 | // ForContributors generates a cache key for contributor data with versioning 18 | // Format: contributors/v1.2/{owner}--{repo}.{ext} 19 | func ForContributors(r *model.Repository, ext string) string { 20 | return fmt.Sprintf("contributors/v1.2/%s--%s.%s", r.Owner, r.RepoName, ext) 21 | } 22 | 23 | // ForImage generates a cache key for repository image 24 | // Format: image/{owner}--{repo}--{anon|noanon}_{maxCount}_{columns}.{ext} 25 | func ForImage(r *model.Repository, options *renderer.RendererOptions, ext string, includeAnonymous bool) string { 26 | anonStr := "noanon" 27 | if includeAnonymous { 28 | anonStr = "anon" 29 | } 30 | 31 | return fmt.Sprintf("image/%s--%s--%s_%d_%d.%s", 32 | r.Owner, r.RepoName, anonStr, options.MaxCount, options.Columns, ext) 33 | } 34 | -------------------------------------------------------------------------------- /apps/api/internal/service/internal/cachekey/keys_test.go: -------------------------------------------------------------------------------- 1 | package cachekey 2 | 3 | import ( 4 | "testing" 5 | 6 | "contrib.rocks/apps/api/go/model" 7 | "contrib.rocks/apps/api/go/renderer" 8 | ) 9 | 10 | func TestForRepository(t *testing.T) { 11 | repo := &model.Repository{ 12 | Owner: "owner", 13 | RepoName: "repo", 14 | } 15 | 16 | key := ForRepository(repo, "json") 17 | expected := "repo/owner--repo.json" 18 | 19 | if key != expected { 20 | t.Errorf("Expected key to be %s, got %s", expected, key) 21 | } 22 | } 23 | 24 | func TestForContributors(t *testing.T) { 25 | repo := &model.Repository{ 26 | Owner: "owner", 27 | RepoName: "repo", 28 | } 29 | 30 | key := ForContributors(repo, "json") 31 | expected := "contributors/v1.2/owner--repo.json" 32 | 33 | if key != expected { 34 | t.Errorf("Expected key to be %s, got %s", expected, key) 35 | } 36 | } 37 | 38 | func TestForImage(t *testing.T) { 39 | repo := &model.Repository{ 40 | Owner: "owner", 41 | RepoName: "repo", 42 | } 43 | 44 | options := &renderer.RendererOptions{ 45 | MaxCount: 100, 46 | Columns: 12, 47 | } 48 | 49 | t.Run("with includeAnonymous=true", func(t *testing.T) { 50 | key := ForImage(repo, options, "svg", true) 51 | expected := "image/owner--repo--anon_100_12.svg" 52 | 53 | if key != expected { 54 | t.Errorf("Expected key to be %s, got %s", expected, key) 55 | } 56 | }) 57 | 58 | t.Run("with includeAnonymous=false", func(t *testing.T) { 59 | key := ForImage(repo, options, "svg", false) 60 | expected := "image/owner--repo--noanon_100_12.svg" 61 | 62 | if key != expected { 63 | t.Errorf("Expected key to be %s, got %s", expected, key) 64 | } 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /apps/api/internal/service/internal/cacheutil/logs.go: -------------------------------------------------------------------------------- 1 | // Package cacheutil provides common utilities for cache operations 2 | package cacheutil 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | 8 | "contrib.rocks/apps/api/internal/logger" 9 | ) 10 | 11 | // LogCacheMiss logs cache miss events in a standardized format 12 | func LogCacheMiss(ctx context.Context, cacheType string, key string) { 13 | logGroup := fmt.Sprintf("%s-cache-miss", cacheType) 14 | logger.LoggerFromContext(ctx).With(logger.LogGroup(logGroup)).Info( 15 | fmt.Sprintf("%s: %s", logGroup, key), 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /apps/api/internal/service/services.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "contrib.rocks/apps/api/go/apiclient" 5 | "contrib.rocks/apps/api/internal/config" 6 | "contrib.rocks/apps/api/internal/github" 7 | "contrib.rocks/apps/api/internal/service/contributors" 8 | "contrib.rocks/apps/api/internal/service/image" 9 | "contrib.rocks/apps/api/internal/service/internal/appcache" 10 | "contrib.rocks/apps/api/internal/service/usage" 11 | ) 12 | 13 | type ServicePack struct { 14 | ContributorsService *contributors.Service 15 | UsageService *usage.Service 16 | ImageService *image.Service 17 | } 18 | 19 | func NewServicePack(cfg *config.Config) *ServicePack { 20 | ghProvider := github.NewProvider(cfg.GitHubAuthToken) 21 | 22 | var cache appcache.AppCache 23 | if cfg.GoogleCredentials() != nil && cfg.CacheBucketName != "" { 24 | storageClient := apiclient.NewStorageClient() 25 | cache = appcache.NewGCSCache(storageClient, cfg.CacheBucketName) 26 | } else { 27 | cache = appcache.NewMemoryCache() 28 | } 29 | 30 | return &ServicePack{ 31 | ContributorsService: contributors.New(ghProvider, cache), 32 | ImageService: image.New(cache), 33 | UsageService: usage.New(), 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/api/internal/service/usage/service.go: -------------------------------------------------------------------------------- 1 | package usage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "contrib.rocks/apps/api/go/model" 8 | "contrib.rocks/apps/api/internal/logger" 9 | "contrib.rocks/apps/api/internal/tracing" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | const ( 14 | logGroupID = "repository-usage" 15 | ) 16 | 17 | func New() *Service { 18 | return &Service{} 19 | } 20 | 21 | type Service struct{} 22 | 23 | func (s *Service) CollectUsage(c context.Context, r *model.RepositoryContributors, via string) error { 24 | ctx, span := tracing.Tracer().Start(c, "usage.Service.CollectUsage") 25 | defer span.End() 26 | 27 | log := logger.LoggerFromContext(ctx) 28 | log = log.With(logger.LogGroup(logGroupID), logger.Label("via", via)) 29 | log.Info(fmt.Sprintf("repository-usage: %s", r.Repository.String()), 30 | zap.String("owner", r.Repository.Owner), 31 | zap.String("repository", r.Repository.String()), 32 | zap.Int("stargazers", r.StargazersCount), 33 | zap.Int("contributors", len(r.Contributors)), 34 | ) 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /apps/api/internal/testing/.env: -------------------------------------------------------------------------------- 1 | GITHUB_AUTH_TOKEN=test 2 | APP_ENV= 3 | CACHE_STORAGE_BUCKET= -------------------------------------------------------------------------------- /apps/api/internal/tracing/middleware.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "context" 5 | 6 | "contrib.rocks/apps/api/internal/config" 7 | "github.com/gin-gonic/gin" 8 | "go.opentelemetry.io/otel" 9 | "go.opentelemetry.io/otel/attribute" 10 | "go.opentelemetry.io/otel/propagation" 11 | ) 12 | 13 | type contextKey int 14 | 15 | const ( 16 | traceNameContextKey contextKey = iota 17 | ) 18 | 19 | func Middleware(cfg *config.Config) gin.HandlerFunc { 20 | 21 | return func(c *gin.Context) { 22 | originalCtx := c.Request.Context() 23 | defer func() { 24 | c.Request = c.Request.WithContext(originalCtx) 25 | }() 26 | ctx := otel.GetTextMapPropagator().Extract(originalCtx, propagation.HeaderCarrier(c.Request.Header)) 27 | ctx, span := Tracer().Start(ctx, "api.http") 28 | defer span.End() 29 | span.SetAttributes(attribute.String("/app/environment", string(cfg.Env))) 30 | 31 | ctx = context.WithValue(ctx, traceNameContextKey, buildTraceName(cfg.ProjectID(), span.SpanContext().TraceID().String())) 32 | c.Request = c.Request.WithContext(ctx) 33 | c.Next() 34 | } 35 | } 36 | 37 | func TraceNameFromContext(c context.Context) string { 38 | if traceName, ok := c.Value(traceNameContextKey).(string); ok { 39 | return traceName 40 | } 41 | return "" 42 | } 43 | -------------------------------------------------------------------------------- /apps/api/internal/tracing/tracing.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "contrib.rocks/apps/api/go/env" 9 | "contrib.rocks/apps/api/internal/config" 10 | cloudtrace "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace" 11 | gcppropagator "github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator" 12 | "go.opentelemetry.io/otel" 13 | ocbridge "go.opentelemetry.io/otel/bridge/opencensus" 14 | "go.opentelemetry.io/otel/propagation" 15 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 16 | "go.opentelemetry.io/otel/trace" 17 | "google.golang.org/api/option" 18 | ) 19 | 20 | func Tracer() trace.Tracer { 21 | return otel.GetTracerProvider().Tracer("") 22 | } 23 | 24 | func installTraceProvider(cfg *config.Config) *sdktrace.TracerProvider { 25 | // Disable telemetry by Cloud Trace itself. 26 | // https://github.com/open-telemetry/opentelemetry-go/issues/1928#issuecomment-843644237 27 | exporter, err := cloudtrace.New( 28 | cloudtrace.WithTraceClientOptions([]option.ClientOption{option.WithTelemetryDisabled()}), 29 | ) 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | tpOpts := []sdktrace.TracerProviderOption{sdktrace.WithBatcher(exporter)} 34 | if cfg.Env == env.EnvDevelopment { 35 | tpOpts = append(tpOpts, sdktrace.WithSampler(sdktrace.AlwaysSample())) 36 | } 37 | tp := sdktrace.NewTracerProvider(tpOpts...) 38 | otel.SetTracerProvider(tp) 39 | return tp 40 | } 41 | 42 | func installPropagators() { 43 | otel.SetTextMapPropagator( 44 | propagation.NewCompositeTextMapPropagator( 45 | gcppropagator.CloudTraceOneWayPropagator{}, 46 | propagation.TraceContext{}, 47 | propagation.Baggage{}, 48 | )) 49 | } 50 | 51 | func installOpenCensusBridge(tp *sdktrace.TracerProvider) { 52 | ocbridge.InstallTraceBridge(ocbridge.WithTracerProvider(tp)) 53 | } 54 | 55 | func InitTraceProvider(cfg *config.Config) func() { 56 | tp := installTraceProvider(cfg) 57 | installPropagators() 58 | installOpenCensusBridge(tp) 59 | return func() { 60 | tp.Shutdown(context.Background()) 61 | } 62 | } 63 | 64 | func buildTraceName(projectID string, traceID string) string { 65 | if projectID == "" || traceID == "" { 66 | return "" 67 | } 68 | return fmt.Sprintf("projects/%s/traces/%s", projectID, traceID) 69 | } 70 | -------------------------------------------------------------------------------- /apps/api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | app "contrib.rocks/apps/api/internal" 7 | "github.com/joho/godotenv" 8 | ) 9 | 10 | func main() { 11 | log.SetFlags(0) 12 | godotenv.Load() 13 | log.Fatal(app.StartServer()) 14 | } 15 | -------------------------------------------------------------------------------- /apps/api/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "namedInputs": { 5 | "default": ["{workspaceRoot}/go.mod", "{projectRoot}/**/*"], 6 | "app": ["!{projectRoot}/**/*_spec.go"] 7 | }, 8 | "tags": ["app"], 9 | "implicitDependencies": ["golib"], 10 | "targets": { 11 | "serve": { 12 | "executor": "nx:run-commands", 13 | "inputs": ["default", "app"], 14 | "options": { 15 | "command": "go run .", 16 | "cwd": "apps/api" 17 | } 18 | }, 19 | "test": { 20 | "executor": "nx:run-commands", 21 | "inputs": ["default"], 22 | "options": { 23 | "command": "go test ./...", 24 | "cwd": "apps/api" 25 | } 26 | }, 27 | "lint": { 28 | "executor": "nx:run-commands", 29 | "inputs": ["default"], 30 | "options": { 31 | "command": "go vet ./...", 32 | "cwd": "apps/api" 33 | } 34 | }, 35 | "format": { 36 | "executor": "nx:run-commands", 37 | "inputs": ["default"], 38 | "options": { 39 | "command": "gofmt -w .", 40 | "cwd": "apps/api" 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/webapp/.postcssrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "@tailwindcss/postcss": {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /apps/webapp/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webapp", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "namedInputs": { 5 | "default": ["{projectRoot}/**/*"], 6 | "prod": ["!{projectRoot}/**/*.spec.ts"] 7 | }, 8 | "tags": ["app"], 9 | "sourceRoot": "apps/webapp/src", 10 | "projectType": "application", 11 | "prefix": "app", 12 | "targets": { 13 | "build": { 14 | "executor": "@angular/build:application", 15 | "options": { 16 | "outputPath": "dist/apps/webapp", 17 | "index": "apps/webapp/src/index.html", 18 | "polyfills": ["zone.js"], 19 | "tsConfig": "apps/webapp/tsconfig.app.json", 20 | "assets": ["apps/webapp/src/favicon.ico", "apps/webapp/src/assets"], 21 | "styles": ["./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", "apps/webapp/src/styles.scss"], 22 | "scripts": [], 23 | "outputHashing": "all", 24 | "browser": "apps/webapp/src/main.ts" 25 | }, 26 | "configurations": { 27 | "production": { 28 | "fileReplacements": [ 29 | { 30 | "replace": "apps/webapp/src/environments/environment.ts", 31 | "with": "apps/webapp/src/environments/environment.prod.ts" 32 | } 33 | ], 34 | "budgets": [ 35 | { 36 | "type": "initial", 37 | "maximumWarning": "2mb", 38 | "maximumError": "5mb" 39 | }, 40 | { 41 | "type": "anyComponentStyle", 42 | "maximumWarning": "6kb" 43 | } 44 | ] 45 | }, 46 | "staging": { 47 | "fileReplacements": [ 48 | { 49 | "replace": "apps/webapp/src/environments/environment.ts", 50 | "with": "apps/webapp/src/environments/environment.staging.ts" 51 | } 52 | ], 53 | "budgets": [ 54 | { 55 | "type": "initial", 56 | "maximumWarning": "2mb", 57 | "maximumError": "5mb" 58 | }, 59 | { 60 | "type": "anyComponentStyle", 61 | "maximumWarning": "6kb" 62 | } 63 | ] 64 | }, 65 | "development": { 66 | "extractLicenses": false, 67 | "sourceMap": true, 68 | "optimization": false, 69 | "namedChunks": true 70 | } 71 | }, 72 | "defaultConfiguration": "production" 73 | }, 74 | "serve": { 75 | "executor": "@angular/build:dev-server", 76 | "options": { 77 | "proxyConfig": "apps/webapp/proxy.conf.json", 78 | "buildTarget": "webapp:build:development" 79 | }, 80 | "configurations": { 81 | "production": { 82 | "buildTarget": "webapp:build:production" 83 | } 84 | } 85 | }, 86 | "extract-i18n": { 87 | "executor": "@angular/build:extract-i18n", 88 | "options": { 89 | "buildTarget": "webapp:build" 90 | } 91 | }, 92 | "lint": { 93 | "executor": "nx:run-commands", 94 | "options": { 95 | "command": "npx eslint . --ext .ts,.html", 96 | "cwd": "apps/webapp" 97 | } 98 | }, 99 | "test": { 100 | "executor": "@angular/build:karma", 101 | "options": { 102 | "polyfills": ["zone.js", "zone.js/testing"], 103 | "tsConfig": "apps/webapp/tsconfig.spec.json", 104 | "assets": ["apps/webapp/src/favicon.ico", "apps/webapp/src/assets"], 105 | "styles": ["./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", "apps/webapp/src/styles.scss"], 106 | "browsers": "ChromeHeadless", 107 | "watch": false 108 | } 109 | }, 110 | "format": { 111 | "executor": "nx:run-commands", 112 | "inputs": ["default"], 113 | "options": { 114 | "command": "npx prettier -w '.'", 115 | "cwd": "apps/webapp" 116 | } 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /apps/webapp/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:3333", 4 | "secure": false 5 | }, 6 | "/image": { 7 | "target": "http://localhost:3333", 8 | "secure": false 9 | }, 10 | "/image2": { 11 | "target": "http://localhost:3333", 12 | "secure": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/webapp/src/app/app-routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { PreviewPageComponent } from './pages/preview/preview.component'; 3 | 4 | export const routes: Routes = [ 5 | { 6 | path: '', 7 | pathMatch: 'full', 8 | redirectTo: 'preview', 9 | }, 10 | { 11 | path: 'preview', 12 | component: PreviewPageComponent, 13 | }, 14 | ]; 15 | -------------------------------------------------------------------------------- /apps/webapp/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | height: 100%; 4 | width: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /apps/webapp/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { TestBed, waitForAsync } from '@angular/core/testing'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(waitForAsync(() => { 7 | TestBed.configureTestingModule({ 8 | schemas: [NO_ERRORS_SCHEMA], 9 | }).compileComponents(); 10 | })); 11 | 12 | it('should create the app', () => { 13 | const fixture = TestBed.createComponent(AppComponent); 14 | const app = fixture.debugElement.componentInstance; 15 | expect(app).toBeTruthy(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /apps/webapp/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterOutlet } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | template: ``, 7 | styleUrls: ['./app.component.scss'], 8 | imports: [RouterOutlet], 9 | }) 10 | export class AppComponent { 11 | onRouteActivate() { 12 | // this.cdRef.detectChanges(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/webapp/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient } from '@angular/common/http'; 2 | import { ApplicationConfig } from '@angular/core'; 3 | import { getAnalytics, provideAnalytics } from '@angular/fire/analytics'; 4 | import { initializeApp, provideFirebaseApp } from '@angular/fire/app'; 5 | import { getFirestore, provideFirestore } from '@angular/fire/firestore'; 6 | import { getPerformance, providePerformance } from '@angular/fire/performance'; 7 | import { provideAnimations } from '@angular/platform-browser/animations'; 8 | import { provideRouter } from '@angular/router'; 9 | import { environment } from '../environments/environment'; 10 | import { routes } from './app-routes'; 11 | import { provideFeaturedRepositoryDatasource } from './shared/featured-repository/firestore'; 12 | 13 | export const appConfig: ApplicationConfig = { 14 | providers: [ 15 | provideAnimations(), 16 | provideHttpClient(), 17 | provideRouter(routes), 18 | provideFirebaseApp(() => initializeApp(environment.firebaseConfig)), 19 | provideFirestore(() => getFirestore()), 20 | provideAnalytics(() => getAnalytics()), 21 | providePerformance(() => getPerformance()), 22 | provideFeaturedRepositoryDatasource(), 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /apps/webapp/src/app/components/svg-view/svg-view.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/angular'; 2 | import { SvgViewComponent } from './svg-view.component'; 3 | 4 | describe('SvgViewComponent', () => { 5 | it('should render', async () => { 6 | await render(SvgViewComponent, { 7 | componentInputs: { 8 | content: 'test svg', 9 | }, 10 | }); 11 | expect(screen.getByText('test svg')).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /apps/webapp/src/app/components/svg-view/svg-view.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject, input } from '@angular/core'; 2 | import { DomSanitizer } from '@angular/platform-browser'; 3 | 4 | @Component({ 5 | selector: 'app-svg-view', 6 | template: `@if (sanitizedSvg) { 7 |
8 | }`, 9 | styles: [ 10 | ` 11 | app-svg-view svg { 12 | max-width: 100%; 13 | height: auto; 14 | } 15 | `, 16 | ], 17 | changeDetection: ChangeDetectionStrategy.OnPush, 18 | encapsulation: ViewEncapsulation.None, 19 | imports: [], 20 | }) 21 | export class SvgViewComponent { 22 | private readonly domSanitizer = inject(DomSanitizer); 23 | 24 | readonly content = input(null); 25 | 26 | get sanitizedSvg() { 27 | const content = this.content(); 28 | if (content === null) { 29 | return null; 30 | } 31 | return this.domSanitizer.bypassSecurityTrustHtml(content); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/webapp/src/app/models/image-params.ts: -------------------------------------------------------------------------------- 1 | import { Repository } from './repository'; 2 | 3 | export interface ImageParamsFormValue { 4 | repository: string | null; 5 | max: number | null; 6 | columns: number | null; 7 | } 8 | 9 | export interface ImageParams { 10 | repository: Repository; 11 | max: number | null; 12 | columns: number | null; 13 | } 14 | -------------------------------------------------------------------------------- /apps/webapp/src/app/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './repository'; 2 | -------------------------------------------------------------------------------- /apps/webapp/src/app/models/repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Repository } from './repository'; 2 | 3 | describe('Repository', () => { 4 | it('should create', () => { 5 | const repo = new Repository('foo', 'bar'); 6 | expect(repo.owner).toBe('foo'); 7 | expect(repo.repo).toBe('bar'); 8 | }); 9 | 10 | it('should create from repository string', () => { 11 | const repo = Repository.fromString('foo/bar'); 12 | expect(repo.owner).toBe('foo'); 13 | expect(repo.repo).toBe('bar'); 14 | }); 15 | 16 | it('should return a repository string', () => { 17 | const repo = new Repository('foo', 'bar'); 18 | expect(repo.toString()).toBe('foo/bar'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /apps/webapp/src/app/models/repository.ts: -------------------------------------------------------------------------------- 1 | export class Repository { 2 | constructor( 3 | public owner: string, 4 | public repo: string, 5 | ) {} 6 | 7 | static fromString(repoStr: string): Repository { 8 | const [owner, repo] = repoStr.split('/'); 9 | return new Repository(owner, repo); 10 | } 11 | 12 | toString(): string { 13 | return `${this.owner}/${this.repo}`; 14 | } 15 | } 16 | 17 | export interface FeaturedRepository { 18 | repository: string; 19 | stargazers: number; 20 | contributors: number; 21 | } 22 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .container { 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | gap: 8px; 10 | } 11 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/footer/footer.component.spec.ts: -------------------------------------------------------------------------------- 1 | describe('FooterComponent', () => { 2 | it('should create', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-footer', 5 | template: ` 6 |
7 | 8 | © 2019 - Suguru Inatomi @ 9 | lacolaco 10 | 11 |
12 | GitHub / 13 | Become a sponsor 14 |
15 |
16 | `, 17 | styleUrls: ['./footer.component.scss'], 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | standalone: true, 20 | }) 21 | export class FooterComponent {} 22 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/header/header.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | row-gap: 8px; 6 | } 7 | 8 | .heading { 9 | font-size: 3rem; 10 | font-weight: 700; 11 | margin: 0; 12 | } 13 | 14 | .discription { 15 | text-align: center; 16 | line-height: 1.2rem; 17 | font-size: 0.9rem; 18 | } 19 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/header/header.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/angular'; 2 | import { HeaderComponent } from './header.component'; 3 | 4 | describe('HeaderComponent', () => { 5 | it('should render', async () => { 6 | await render(HeaderComponent); 7 | 8 | expect(screen.getByText('contrib.rocks')).toBeTruthy(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { MatButtonModule } from '@angular/material/button'; 3 | 4 | @Component({ 5 | selector: 'app-header', 6 | template: ` 7 |

contrib.rocks

8 |
Generate an image of contributors to keep your README.md in sync.
9 | 12 | `, 13 | styleUrls: ['./header.component.scss'], 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | imports: [MatButtonModule], 16 | }) 17 | export class HeaderComponent {} 18 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-preview-form/image-preview-form.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | width: 100%; 4 | } 5 | 6 | .controlPane { 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: center; 10 | align-items: center; 11 | 12 | > :not(:last-child) { 13 | margin-bottom: 16px; 14 | } 15 | } 16 | 17 | .repositoryForm { 18 | display: flex; 19 | flex-direction: row; 20 | flex-wrap: wrap; 21 | justify-content: space-between; 22 | align-items: center; 23 | 24 | > :not(:last-child) { 25 | margin-right: 32px; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-preview-form/image-preview-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | describe('RepositoryFormComponent', () => { 2 | it('should create', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-preview-form/image-preview-form.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core'; 2 | import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { MatFormFieldModule } from '@angular/material/form-field'; 5 | import { MatInputModule } from '@angular/material/input'; 6 | import { ImageParams } from '../../../models/image-params'; 7 | import { Repository } from '../../../models/repository'; 8 | import { PreviewState } from '../state'; 9 | 10 | @Component({ 11 | selector: 'app-image-preview-form', 12 | template: ` 13 |
14 |
15 | 16 | Enter GitHub Repository 17 | 18 | 19 | 22 |
23 |
24 | `, 25 | styleUrls: ['./image-preview-form.component.scss'], 26 | changeDetection: ChangeDetectionStrategy.OnPush, 27 | imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule], 28 | }) 29 | export class ImagePreviewFormComponent { 30 | @Input() 31 | set value(value: ImageParams) { 32 | this.form.patchValue({ repository: value.repository.toString() }); 33 | } 34 | 35 | private readonly store = inject(PreviewState); 36 | private readonly fb = inject(FormBuilder).nonNullable; 37 | 38 | readonly form = this.fb.group({ 39 | repository: this.fb.control('', { 40 | validators: [Validators.required], 41 | }), 42 | }); 43 | 44 | generateImage() { 45 | const repoName = this.form.getRawValue().repository; 46 | 47 | this.store.patchImageParams({ 48 | repository: Repository.fromString(repoName), 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-preview-result/image-preview-result.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | :host { 4 | display: block; 5 | } 6 | 7 | .pane { 8 | @include mat.elevation(4); 9 | 10 | min-width: 50vw; 11 | padding: 16px; 12 | border-radius: 8px; 13 | background: transparent; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | 18 | > :not(:last-child) { 19 | margin-bottom: 16px; 20 | } 21 | 22 | transition: height 300ms; 23 | } 24 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-preview-result/image-preview-result.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient } from '@angular/common/http'; 2 | import { provideHttpClientTesting } from '@angular/common/http/testing'; 3 | import { render, screen } from '@testing-library/angular'; 4 | import { Repository } from '../../../models'; 5 | import { ImagePreviewResultComponent } from './image-preview-result.component'; 6 | 7 | describe('ImagePreviewResultComponent', () => { 8 | it('should render', async () => { 9 | await render(ImagePreviewResultComponent, { 10 | providers: [provideHttpClient(), provideHttpClientTesting()], 11 | componentInputs: { 12 | repository: Repository.fromString('foo/bar'), 13 | imageSvg: 'foo', 14 | }, 15 | }); 16 | 17 | expect(screen.getByText('foo')).toBeTruthy(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-preview-result/image-preview-result.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, OnChanges, input } from '@angular/core'; 2 | import { MatButtonModule } from '@angular/material/button'; 3 | import { SvgViewComponent } from '../../../components/svg-view/svg-view.component'; 4 | import { Repository } from '../../../models'; 5 | import { ImageSnippetComponent } from '../image-snippet/image-snippet.component'; 6 | 7 | @Component({ 8 | selector: 'app-image-preview-result', 9 | template: ` 10 |
11 | 12 | @if (snippetOpen) { 13 | 14 | } 15 | @if (!snippetOpen) { 16 | 17 | } 18 |
19 | `, 20 | styleUrls: ['./image-preview-result.component.scss'], 21 | changeDetection: ChangeDetectionStrategy.OnPush, 22 | imports: [MatButtonModule, SvgViewComponent, ImageSnippetComponent], 23 | }) 24 | export class ImagePreviewResultComponent implements OnChanges { 25 | readonly repository = input.required(); 26 | readonly imageSvg = input.required(); 27 | 28 | snippetOpen = false; 29 | 30 | ngOnChanges() { 31 | this.snippetOpen = false; 32 | } 33 | 34 | showImageSnippet() { 35 | this.snippetOpen = true; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-preview/image-preview.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | row-gap: 16px; 7 | } 8 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-preview/image-preview.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { PreviewState } from '../state'; 3 | 4 | import { ImagePreviewComponent } from './image-preview.component'; 5 | import { provideNoopAnimations } from '@angular/platform-browser/animations'; 6 | 7 | describe('ImagePreviewComponent', () => { 8 | let component: ImagePreviewComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(async () => { 12 | await TestBed.configureTestingModule({ 13 | imports: [ImagePreviewComponent], 14 | providers: [PreviewState, provideNoopAnimations()], 15 | }).compileComponents(); 16 | 17 | fixture = TestBed.createComponent(ImagePreviewComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-preview/image-preview.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; 3 | import { map } from 'rxjs'; 4 | import { ImagePreviewFormComponent } from '../image-preview-form/image-preview-form.component'; 5 | import { ImagePreviewResultComponent } from '../image-preview-result/image-preview-result.component'; 6 | import { PreviewState } from '../state'; 7 | 8 | @Component({ 9 | selector: 'app-image-preview', 10 | template: ` 11 | @if (state$ | async; as state) { 12 | 13 | @if (state.loading) { 14 | Loading... 15 | } @else { 16 | @if (state.result) { 17 | 18 | } @else { 19 |
No Result. Is the repository name correct?
20 | } 21 | } 22 | } 23 | `, 24 | styleUrls: ['./image-preview.component.scss'], 25 | changeDetection: ChangeDetectionStrategy.OnPush, 26 | imports: [CommonModule, ImagePreviewFormComponent, ImagePreviewResultComponent], 27 | }) 28 | export class ImagePreviewComponent { 29 | private readonly state = inject(PreviewState); 30 | 31 | readonly state$ = this.state.select().pipe( 32 | map((state) => ({ 33 | imageParams: state.imageParams, 34 | loading: state.fetchingCount > 0, 35 | result: state.result, 36 | })), 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-snippet/image-snippet.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .heading { 8 | font-weight: bold; 9 | padding-bottom: 8px; 10 | } 11 | 12 | .snippet { 13 | margin-bottom: 8px; 14 | padding: 8px; 15 | font-family: monospace; 16 | } 17 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-snippet/image-snippet.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ClipboardModule } from '@angular/cdk/clipboard'; 2 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 3 | import { render } from '@testing-library/angular'; 4 | import { Repository } from '../../../models/repository'; 5 | import { ImageSnippetComponent } from './image-snippet.component'; 6 | 7 | describe('ImageSnippetComponent', () => { 8 | it('should render', async () => { 9 | const { fixture } = await render(ImageSnippetComponent, { 10 | imports: [ClipboardModule, MatSnackBarModule], 11 | componentInputs: { 12 | repository: Repository.fromString('foo/bar'), 13 | }, 14 | }); 15 | 16 | const repoString = 'foo/bar'; 17 | const expectedSnippet = ` 18 | 19 | 20 | 21 | 22 | Made with [contrib.rocks](${location.origin}).`.trim(); 23 | expect(fixture.componentInstance.imageSnippet).toEqual(expectedSnippet); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/image-snippet/image-snippet.component.ts: -------------------------------------------------------------------------------- 1 | import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; 2 | import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; 3 | import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; 4 | import { Repository } from '../../../models/repository'; 5 | 6 | @Component({ 7 | selector: 'app-image-snippet', 8 | template: ` 9 |
Copy & Paste to README.md
10 | 11 |
12 | Available options (Add to image URL query params) 13 |
14 |
{{ 'max={number}' }}
15 |
Max contributor count (100 by default)
16 |
{{ 'columns={number}' }}
17 |
Max columns (12 by default)
18 |
{{ 'anon={0|1}' }}
19 |
Include anonymous users (false by default)
20 |
21 |
22 | `, 23 | styleUrls: ['./image-snippet.component.scss'], 24 | changeDetection: ChangeDetectionStrategy.OnPush, 25 | imports: [ClipboardModule, MatSnackBarModule], 26 | }) 27 | export class ImageSnippetComponent { 28 | private readonly clipboard = inject(Clipboard); 29 | private readonly snackBar = inject(MatSnackBar); 30 | 31 | readonly repository = input.required(); 32 | 33 | get imageSnippet(): string { 34 | const repoString = this.repository().toString(); 35 | return ` 36 | 37 | 38 | 39 | 40 | Made with [contrib.rocks](${location.origin}).`.trim(); 41 | } 42 | 43 | copyToClipboard() { 44 | this.clipboard.copy(this.imageSnippet); 45 | this.snackBar.open('Copied to clipboard!', undefined, { duration: 2000 }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/preview.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | height: 100%; 4 | width: 100%; 5 | } 6 | 7 | main { 8 | height: 100%; 9 | padding: 32px 5%; 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: center; 13 | align-items: center; 14 | row-gap: 16px; 15 | 16 | @media (min-width: 768px) { 17 | padding: 64px 10%; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/preview.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient } from '@angular/common/http'; 2 | import { provideHttpClientTesting } from '@angular/common/http/testing'; 3 | import { render, screen } from '@testing-library/angular'; 4 | import { provideNoopFeaturedRepositoryDatasource } from '../../shared/featured-repository/noop'; 5 | import { PreviewPageComponent } from './preview.component'; 6 | 7 | describe('PreviewComponent', () => { 8 | it('should render', async () => { 9 | await render(PreviewPageComponent, { 10 | providers: [provideHttpClient(), provideHttpClientTesting(), provideNoopFeaturedRepositoryDatasource()], 11 | }); 12 | 13 | expect(screen.getByText('Generate an image of contributors to keep your README.md in sync.')).toBeTruthy(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/preview.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, OnDestroy, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, Router } from '@angular/router'; 3 | import { delay, firstValueFrom, Subject, takeUntil } from 'rxjs'; 4 | import { ImageParams } from '../../models/image-params'; 5 | import { Repository } from '../../models/repository'; 6 | import { ContributorsImageApi } from '../../shared/api/contributors-image'; 7 | import { FooterComponent } from './footer/footer.component'; 8 | import { HeaderComponent } from './header/header.component'; 9 | import { ImagePreviewComponent } from './image-preview/image-preview.component'; 10 | import { RecentUsageComponent } from './recent-usage/recent-usage.component'; 11 | import { defaultImageParams, PreviewState } from './state'; 12 | 13 | interface PreviewPageParams { 14 | repo?: string; 15 | max?: string; 16 | columns?: string; 17 | } 18 | 19 | @Component({ 20 | template: ` 21 |
22 | 23 | 24 | 25 | 26 |
27 | `, 28 | styleUrls: ['./preview.component.scss'], 29 | imports: [HeaderComponent, FooterComponent, ImagePreviewComponent, RecentUsageComponent], 30 | providers: [PreviewState], 31 | }) 32 | export class PreviewPageComponent implements OnInit, OnDestroy { 33 | private readonly onDestroy$ = new Subject(); 34 | private readonly state = inject(PreviewState); 35 | private readonly router = inject(Router); 36 | private readonly imageApi = inject(ContributorsImageApi); 37 | private readonly queryParams$ = inject(ActivatedRoute).queryParams.pipe(takeUntil(this.onDestroy$)); 38 | 39 | ngOnInit() { 40 | this.updateStateOnQueryParamsChange(); 41 | this.refreshOnStateChange(); 42 | } 43 | 44 | ngOnDestroy(): void { 45 | this.onDestroy$.next(); 46 | } 47 | 48 | private updateStateOnQueryParamsChange() { 49 | this.queryParams$.subscribe((params) => { 50 | const { repo = null, max = null, columns = null } = params; 51 | this.state.patchImageParams({ 52 | repository: repo ? Repository.fromString(repo) : defaultImageParams.repository, 53 | max: Number(max) || defaultImageParams.max, 54 | columns: Number(columns) || defaultImageParams.columns, 55 | }); 56 | }); 57 | } 58 | 59 | private refreshOnStateChange() { 60 | this.state 61 | .select('imageParams') 62 | .pipe(takeUntil(this.onDestroy$)) 63 | .subscribe(async (imageParams) => { 64 | window.scrollTo({ top: 0, behavior: 'smooth' }); 65 | this.updateQueryParams(imageParams); 66 | try { 67 | this.state.startFetchingImage(); 68 | const image = await firstValueFrom(this.imageApi.getImage(imageParams).pipe(delay(100))); 69 | this.state.finishFetchingImage({ data: image }); 70 | } catch { 71 | this.state.finishFetchingImage(null); 72 | } 73 | }); 74 | } 75 | 76 | private async updateQueryParams(params: ImageParams) { 77 | const pageParams: PreviewPageParams = { 78 | repo: params.repository.toString(), 79 | max: params.max?.toString() ?? undefined, 80 | columns: params.columns?.toString() ?? undefined, 81 | }; 82 | await this.router.navigate([], { queryParams: pageParams }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/recent-usage/recent-usage.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { provideNoopFeaturedRepositoryDatasource } from '../../../shared/featured-repository/noop'; 3 | import { RepositoryGalleryComponent } from '../repository-gallery/repository-gallery.component'; 4 | import { RecentUsageComponent } from './recent-usage.component'; 5 | 6 | describe('RecentUsageComponent', () => { 7 | let component: RecentUsageComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | imports: [RecentUsageComponent, RepositoryGalleryComponent], 13 | providers: [provideNoopFeaturedRepositoryDatasource()], 14 | }).compileComponents(); 15 | }); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(RecentUsageComponent); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/recent-usage/recent-usage.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component, inject, OnDestroy } from '@angular/core'; 3 | import { Observable, Subject, takeUntil } from 'rxjs'; 4 | import { FeaturedRepositoryDatasourceToken } from '../../../shared/featured-repository'; 5 | import { FeaturedRepository } from '../../../models/repository'; 6 | import { RepositoryGalleryComponent } from '../repository-gallery/repository-gallery.component'; 7 | 8 | @Component({ 9 | selector: 'app-recent-usage', 10 | template: ` 11 | @if (repositories$ | async; as repositories) { 12 | 13 | } 14 | `, 15 | styles: [ 16 | ` 17 | :host { 18 | display: block; 19 | width: 100%; 20 | } 21 | `, 22 | ], 23 | imports: [CommonModule, RepositoryGalleryComponent], 24 | }) 25 | export class RecentUsageComponent implements OnDestroy { 26 | private readonly datasource = inject(FeaturedRepositoryDatasourceToken); 27 | private readonly onDestroy$ = new Subject(); 28 | 29 | readonly repositories$: Observable = this.datasource.repositories$.pipe( 30 | takeUntil(this.onDestroy$), 31 | ); 32 | 33 | ngOnDestroy() { 34 | this.onDestroy$.next(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/repository-gallery/repository-gallery.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { provideRouter } from '@angular/router'; 2 | import { render, screen } from '@testing-library/angular'; 3 | import { RepositoryGalleryComponent } from './repository-gallery.component'; 4 | 5 | describe('RepositoryGalleryComponent', () => { 6 | it('should render', async () => { 7 | await render(RepositoryGalleryComponent, { 8 | providers: [provideRouter([])], 9 | componentInputs: { 10 | repositories: [{ repository: 'test/repo', stargazers: 123 }], 11 | }, 12 | }); 13 | 14 | expect(screen.getByText('test/repo')).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/repository-gallery/repository-gallery.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, input } from '@angular/core'; 3 | import { RouterLink } from '@angular/router'; 4 | import { FeaturedRepository, Repository } from '../../../models/repository'; 5 | 6 | @Component({ 7 | selector: 'app-repository-gallery', 8 | template: ` 9 |
Used by
10 | 47 | `, 48 | changeDetection: ChangeDetectionStrategy.OnPush, 49 | imports: [CommonModule, RouterLink], 50 | }) 51 | export class RepositoryGalleryComponent { 52 | readonly repositories = input([]); 53 | 54 | getRepositoryImageUrl(repo: FeaturedRepository): string { 55 | return `https://github.com/${Repository.fromString(repo.repository).owner}.png?w=64`; 56 | } 57 | 58 | getRepositoryPageUrl(repo: FeaturedRepository): string { 59 | return `https://github.com/${repo.repository}`; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/state.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { defaultImageParams, PreviewState } from './state'; 3 | 4 | describe('PreviewState', () => { 5 | let state: PreviewState; 6 | 7 | beforeEach(() => { 8 | TestBed.runInInjectionContext(() => { 9 | state = new PreviewState(); 10 | }); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(state).toBeTruthy(); 15 | }); 16 | 17 | it('should has initial value', () => { 18 | expect(state.get()).toBeDefined(); 19 | expect(state.get().imageParams).toBe(defaultImageParams); 20 | expect(state.get().result).toEqual(null); 21 | expect(state.get().fetchingCount).toEqual(0); 22 | }); 23 | 24 | describe('startFetchingContributors()', () => { 25 | it('should update value', () => { 26 | state.startFetchingImage(); 27 | 28 | expect(state.get().result).toEqual(null); 29 | expect(state.get().fetchingCount).toEqual(1); 30 | }); 31 | }); 32 | 33 | describe('finishFetchingContributors()', () => { 34 | it('should update value', () => { 35 | state.set((state) => ({ 36 | ...state, 37 | fetchingCount: 1, 38 | result: null, 39 | })); 40 | 41 | state.finishFetchingImage(null); 42 | 43 | expect(state.get().fetchingCount).toEqual(0); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /apps/webapp/src/app/pages/preview/state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { RxState } from '@rx-angular/state'; 3 | import { ImageParams } from '../../models/image-params'; 4 | import { Repository } from '../../models/repository'; 5 | 6 | export interface State { 7 | imageParams: ImageParams; 8 | fetchingCount: number; 9 | result: { 10 | data: string; 11 | } | null; 12 | } 13 | 14 | export const defaultImageParams: ImageParams = { 15 | repository: new Repository('angular', 'angular-ja'), 16 | max: null, 17 | columns: null, 18 | }; 19 | 20 | export const initialValue: State = { 21 | imageParams: defaultImageParams, 22 | fetchingCount: 0, 23 | result: null, 24 | }; 25 | 26 | @Injectable() 27 | export class PreviewState extends RxState { 28 | constructor() { 29 | super(); 30 | this.set(initialValue); 31 | } 32 | 33 | startFetchingImage() { 34 | this.set((state) => ({ 35 | ...state, 36 | fetchingCount: state.fetchingCount + 1, 37 | result: null, 38 | })); 39 | } 40 | 41 | finishFetchingImage(result: { data: string } | null) { 42 | this.set((state) => ({ 43 | ...state, 44 | fetchingCount: state.fetchingCount - 1, 45 | result, 46 | })); 47 | } 48 | 49 | patchImageParams(params: Partial) { 50 | this.set((state) => ({ 51 | ...state, 52 | imageParams: { 53 | ...state.imageParams, 54 | ...params, 55 | }, 56 | })); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /apps/webapp/src/app/shared/api/contributors-image.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpParams } from '@angular/common/http'; 2 | import { Injectable, inject } from '@angular/core'; 3 | import { ImageParams } from '../../models/image-params'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class ContributorsImageApi { 7 | private readonly httpClient = inject(HttpClient); 8 | 9 | getImage({ repository, max, columns }: ImageParams) { 10 | const params = new HttpParams({ 11 | fromObject: { 12 | repo: repository.toString(), 13 | preview: true, 14 | max: max ?? '', 15 | columns: columns ?? '', 16 | }, 17 | }); 18 | return this.httpClient.get('/image', { 19 | responseType: 'text', 20 | params, 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/webapp/src/app/shared/featured-repository/firestore.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Provider, inject } from '@angular/core'; 2 | import { doc, docData, DocumentReference, Firestore } from '@angular/fire/firestore'; 3 | import { filter, map, Observable } from 'rxjs'; 4 | import { environment } from '../../../environments/environment'; 5 | import { FeaturedRepository } from '../../models'; 6 | import { FeaturedRepositoryDatasource, FeaturedRepositoryDatasourceToken } from './index'; 7 | 8 | interface FeaturedRepositoryDocument { 9 | items: FeaturedRepository[]; 10 | } 11 | 12 | @Injectable() 13 | export class FirebaseFeaturedRepositoryDatasource implements FeaturedRepositoryDatasource { 14 | readonly repositories$: Observable; 15 | 16 | constructor() { 17 | const firestore = inject(Firestore); 18 | 19 | this.repositories$ = docData( 20 | doc( 21 | firestore, 22 | `${environment.firestoreRootCollectionName}/featured_repositories`, 23 | ) as DocumentReference, 24 | ).pipe( 25 | filter((doc): doc is FeaturedRepositoryDocument => doc != null), 26 | map((doc) => doc.items), 27 | ); 28 | } 29 | } 30 | 31 | export function provideFeaturedRepositoryDatasource(): Provider[] { 32 | return [ 33 | { 34 | provide: FeaturedRepositoryDatasourceToken, 35 | useClass: FirebaseFeaturedRepositoryDatasource, 36 | }, 37 | ]; 38 | } 39 | -------------------------------------------------------------------------------- /apps/webapp/src/app/shared/featured-repository/index.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { FeaturedRepository } from '../../models'; 4 | 5 | export const FeaturedRepositoryDatasourceToken = new InjectionToken( 6 | 'FeaturedRepositoryDatasource', 7 | ); 8 | 9 | export abstract class FeaturedRepositoryDatasource { 10 | abstract readonly repositories$: Observable; 11 | } 12 | -------------------------------------------------------------------------------- /apps/webapp/src/app/shared/featured-repository/noop.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@angular/core'; 2 | import { of } from 'rxjs'; 3 | import { FeaturedRepositoryDatasource, FeaturedRepositoryDatasourceToken } from './index'; 4 | 5 | class NoopFeaturedRepositoryDatasource implements FeaturedRepositoryDatasource { 6 | readonly repositories$ = of([]); 7 | } 8 | 9 | export function provideNoopFeaturedRepositoryDatasource(): Provider[] { 10 | return [ 11 | { 12 | provide: FeaturedRepositoryDatasourceToken, 13 | useFactory: () => new NoopFeaturedRepositoryDatasource(), 14 | }, 15 | ]; 16 | } 17 | -------------------------------------------------------------------------------- /apps/webapp/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lacolaco/contributors-img/04503a2418df1dd391cfc9bbd3e27f32f6d001bd/apps/webapp/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/webapp/src/assets/images/github-64px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lacolaco/contributors-img/04503a2418df1dd391cfc9bbd3e27f32f6d001bd/apps/webapp/src/assets/images/github-64px.png -------------------------------------------------------------------------------- /apps/webapp/src/assets/images/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lacolaco/contributors-img/04503a2418df1dd391cfc9bbd3e27f32f6d001bd/apps/webapp/src/assets/images/loading.gif -------------------------------------------------------------------------------- /apps/webapp/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | import { firebaseConfig } from './firebase-config'; 2 | 3 | export const environment = { 4 | production: true, 5 | firebaseConfig: firebaseConfig.prod, 6 | firestoreRootCollectionName: 'production', 7 | }; 8 | -------------------------------------------------------------------------------- /apps/webapp/src/environments/environment.staging.ts: -------------------------------------------------------------------------------- 1 | import { firebaseConfig } from './firebase-config'; 2 | 3 | export const environment = { 4 | production: true, 5 | firebaseConfig: firebaseConfig.prod, 6 | firestoreRootCollectionName: 'staging', 7 | }; 8 | -------------------------------------------------------------------------------- /apps/webapp/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | import { firebaseConfig } from './firebase-config'; 2 | 3 | export const environment = { 4 | production: false, 5 | firebaseConfig: firebaseConfig.prod, 6 | firestoreRootCollectionName: 'development', 7 | }; 8 | -------------------------------------------------------------------------------- /apps/webapp/src/environments/firebase-config.ts: -------------------------------------------------------------------------------- 1 | export const firebaseConfig = { 2 | prod: { 3 | apiKey: 'AIzaSyAbRYW9Lxc2SwqZZI-vUYN093K6f4AhUmo', 4 | authDomain: 'contributors-img.firebaseapp.com', 5 | databaseURL: 'https://contributors-img.firebaseio.com', 6 | projectId: 'contributors-img', 7 | storageBucket: 'contributors-img.appspot.com', 8 | messagingSenderId: '484218711641', 9 | appId: '1:484218711641:web:5e94514a02e699ddd54b48', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /apps/webapp/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lacolaco/contributors-img/04503a2418df1dd391cfc9bbd3e27f32f6d001bd/apps/webapp/src/favicon.ico -------------------------------------------------------------------------------- /apps/webapp/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | contrib.rocks 6 | 10 | 11 | 12 | 13 | 14 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /apps/webapp/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { bootstrapApplication } from '@angular/platform-browser'; 3 | import { AppComponent } from './app/app.component'; 4 | import { appConfig } from './app/app.config'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)); 12 | -------------------------------------------------------------------------------- /apps/webapp/src/reset.css: -------------------------------------------------------------------------------- 1 | /** 2 | * A modern alternative to CSS resets 3 | * @link https://piccalil.li/blog/a-more-modern-css-reset/ 4 | */ 5 | 6 | /* Box sizing rules */ 7 | *, 8 | *::before, 9 | *::after { 10 | box-sizing: border-box; 11 | } 12 | 13 | /* Prevent font size inflation */ 14 | html { 15 | -moz-text-size-adjust: none; 16 | -webkit-text-size-adjust: none; 17 | text-size-adjust: none; 18 | } 19 | 20 | /* Remove default margin in favour of better control in authored CSS */ 21 | body, 22 | h1, 23 | h2, 24 | h3, 25 | h4, 26 | p, 27 | figure, 28 | blockquote, 29 | dl, 30 | dd { 31 | margin-block-end: 0; 32 | } 33 | 34 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ 35 | ul[role='list'], 36 | ol[role='list'] { 37 | list-style: none; 38 | } 39 | 40 | /* Set core body defaults */ 41 | body { 42 | min-height: 100vh; 43 | line-height: 1.5; 44 | } 45 | 46 | /* Set shorter line heights on headings and interactive elements */ 47 | h1, 48 | h2, 49 | h3, 50 | h4, 51 | button, 52 | input, 53 | label { 54 | line-height: 1.1; 55 | } 56 | 57 | /* Balance text wrapping on headings */ 58 | h1, 59 | h2, 60 | h3, 61 | h4 { 62 | text-wrap: balance; 63 | } 64 | 65 | /* A elements that don't have a class get default styles */ 66 | a:not([class]) { 67 | text-decoration-skip-ink: auto; 68 | color: currentColor; 69 | } 70 | 71 | /* Make images easier to work with */ 72 | img, 73 | picture { 74 | max-width: 100%; 75 | display: block; 76 | } 77 | 78 | /* Inherit fonts for inputs and buttons */ 79 | input, 80 | button, 81 | textarea, 82 | select { 83 | font-family: inherit; 84 | font-size: inherit; 85 | } 86 | 87 | /* Make sure textareas without a rows attribute are not tiny */ 88 | textarea:not([rows]) { 89 | min-height: 10em; 90 | } 91 | 92 | /* Anything that has been anchored to should have extra scroll margin */ 93 | :target { 94 | scroll-margin-block: 5ex; 95 | } 96 | -------------------------------------------------------------------------------- /apps/webapp/src/styles.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | @layer theme, base, components, utilities; 4 | @import 'tailwindcss/theme.css' layer(theme); 5 | @import 'tailwindcss/utilities.css' layer(utilities); 6 | @import './reset.css' layer(base); 7 | 8 | /* You can add global styles to this file, and also import other style files */ 9 | body { 10 | margin: 0; 11 | font-family: 'Montserrat', sans-serif; 12 | } 13 | 14 | // Define a custom typography config that overrides the font-family as well as the 15 | // `headlines` and `body-1` levels. 16 | $custom-typography: mat.m2-define-legacy-typography-config( 17 | $font-family: "'Montserrat', sans-serif", 18 | $headline: mat.m2-define-typography-level(32px, 48px, 700), 19 | $body-1: mat.m2-define-typography-level(16px, 24px, 500), 20 | ); 21 | 22 | .mat-form-field.--no-hint { 23 | .mat-form-field-wrapper { 24 | padding-bottom: unset; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/webapp/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [], 6 | "target": "ES2022", 7 | "useDefineForClassFields": false 8 | }, 9 | "files": ["src/main.ts"], 10 | "include": ["src/**/*.d.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /apps/webapp/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jasmine", "node"], 7 | "emitDecoratorMetadata": true 8 | }, 9 | "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/worker/.gcloudignore: -------------------------------------------------------------------------------- 1 | src 2 | node_modules -------------------------------------------------------------------------------- /apps/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker", 3 | "main": "dist/main.js", 4 | "scripts": { 5 | "start": "node dist/main.js" 6 | }, 7 | "packageManager": "pnpm@10.8.1", 8 | "dependencies": { 9 | "@google-cloud/bigquery": "7.9.2", 10 | "@google-cloud/firestore": "7.11.0", 11 | "@hono/node-server": "1.14.1", 12 | "hono": "4.7.7" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/worker/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "namedInputs": { 5 | "default": ["{projectRoot}/**/*"], 6 | "app": ["!{projectRoot}/**/*.spec.ts"] 7 | }, 8 | "tags": ["app"], 9 | "targets": { 10 | "build": { 11 | "executor": "nx:run-commands", 12 | "inputs": ["default", "app"], 13 | "options": { 14 | "command": "esbuild src/main.ts --outfile=dist/main.js --tsconfig=tsconfig.app.json --platform=node --bundle --packages=external", 15 | "cwd": "apps/worker" 16 | } 17 | }, 18 | "serve": { 19 | "executor": "nx:run-commands", 20 | "inputs": ["default", "app"], 21 | "options": { 22 | "command": "tsx src/main.ts", 23 | "cwd": "apps/worker" 24 | } 25 | }, 26 | "test": { 27 | "executor": "nx:run-commands", 28 | "inputs": ["default"], 29 | "options": { 30 | "command": "echo 'No testing available for worker'", 31 | "cwd": "apps/worker" 32 | } 33 | }, 34 | "lint": { 35 | "executor": "nx:run-commands", 36 | "inputs": ["default"], 37 | "options": { 38 | "command": "echo 'No linting available for worker'", 39 | "cwd": "apps/worker" 40 | } 41 | }, 42 | "format": { 43 | "executor": "nx:run-commands", 44 | "inputs": ["default"], 45 | "options": { 46 | "command": "npx prettier -w '.'", 47 | "cwd": "apps/worker" 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apps/worker/src/internal/query.ts: -------------------------------------------------------------------------------- 1 | import { BigQuery } from '@google-cloud/bigquery'; 2 | 3 | export interface RepositoryUsageRow { 4 | owner: string; 5 | repository: string; 6 | days: number; 7 | stargazers: number; 8 | contributors: number; 9 | } 10 | 11 | export interface UsageStatsRow { 12 | owners: number; 13 | repositories: number; 14 | } 15 | 16 | export async function queryFeaturedRepositories(): Promise { 17 | const bq = new BigQuery(); 18 | const query = ` 19 | SELECT 20 | owner, 21 | repository, 22 | days, 23 | usage.stargazers AS stargazers, 24 | usage.contributors AS contributors 25 | FROM 26 | \`contributors-img.repository_usage.weekly_repository_usage\` 27 | WHERE 28 | days >= 5 29 | AND usage.stargazers >= 1000 30 | AND usage.contributors >= 50 31 | LIMIT 32 | 100`; 33 | 34 | const [rows] = await bq.query(query); 35 | return rows as RepositoryUsageRow[]; 36 | } 37 | 38 | export async function queryUsageStats(): Promise { 39 | const bq = new BigQuery(); 40 | const query = ` 41 | SELECT 42 | count(DISTINCT owner) AS owners, 43 | count(DISTINCT repository) AS repositories 44 | FROM 45 | \`contributors-img.repository_usage.weekly_repository_usage\` 46 | WHERE 47 | days >= 5`; 48 | 49 | const [rows] = await bq.query(query); 50 | return rows[0] as UsageStatsRow; 51 | } 52 | -------------------------------------------------------------------------------- /apps/worker/src/internal/server.ts: -------------------------------------------------------------------------------- 1 | import { serve } from '@hono/node-server'; 2 | import { Hono } from 'hono'; 3 | import { queryFeaturedRepositories, queryUsageStats } from './query'; 4 | import { saveFeaturedRepositories, saveUsageStats } from './store'; 5 | 6 | export async function startServer(port: number, appEnv: string): Promise { 7 | const app = new Hono(); 8 | 9 | app.all('/update-featured-repositories', async (c) => { 10 | try { 11 | const featuredRepositories = await queryFeaturedRepositories(); 12 | const usageStats = await queryUsageStats(); 13 | const updatedAt = new Date(); 14 | 15 | await saveFeaturedRepositories(appEnv, featuredRepositories, updatedAt); 16 | await saveUsageStats(appEnv, usageStats, updatedAt); 17 | 18 | return c.json({ message: 'Data updated successfully' }, 200); 19 | } catch (error) { 20 | console.error('Failed to update data:', error); 21 | return c.json({ error: 'Failed to update data' }, 500); 22 | } 23 | }); 24 | 25 | app.all('*', (c) => c.json({ error: 'Not Found' }, 404)); 26 | 27 | serve({ fetch: app.fetch, port }, () => { 28 | console.log(`Server running at http://127.0.0.1:${port}/`); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /apps/worker/src/internal/store.ts: -------------------------------------------------------------------------------- 1 | import { Firestore, CollectionReference } from '@google-cloud/firestore'; 2 | import { RepositoryUsageRow, UsageStatsRow } from './query'; 3 | 4 | export interface FeaturedRepository { 5 | repository: string; 6 | stargazers: number; 7 | contributors: number; 8 | } 9 | 10 | export interface FeaturedRepositoriesDocument { 11 | items: FeaturedRepository[]; 12 | updatedAt: Date; 13 | } 14 | 15 | export interface UsageStats { 16 | owners: number; 17 | repositories: number; 18 | } 19 | 20 | export interface UsageStatsDocument { 21 | owners: number; 22 | repositories: number; 23 | updatedAt: Date; 24 | } 25 | 26 | function getEnvironmentCollection(appEnv: string): CollectionReference { 27 | const firestore = new Firestore(); 28 | return firestore.collection(appEnv); 29 | } 30 | 31 | export async function saveFeaturedRepositories( 32 | appEnv: string, 33 | usageRows: RepositoryUsageRow[], 34 | updatedAt: Date, 35 | ): Promise { 36 | const items: FeaturedRepository[] = usageRows.map((row) => ({ 37 | repository: row.repository, 38 | stargazers: row.stargazers, 39 | contributors: row.contributors, 40 | })); 41 | 42 | const data: FeaturedRepositoriesDocument = { 43 | items, 44 | updatedAt, 45 | }; 46 | 47 | await getEnvironmentCollection(appEnv).doc('featured_repositories').set(data); 48 | } 49 | 50 | export async function saveUsageStats(appEnv: string, usageRow: UsageStatsRow, updatedAt: Date): Promise { 51 | const data: UsageStatsDocument = { 52 | owners: usageRow.owners, 53 | repositories: usageRow.repositories, 54 | updatedAt, 55 | }; 56 | 57 | await getEnvironmentCollection(appEnv).doc('usage_stats').set(data); 58 | } 59 | -------------------------------------------------------------------------------- /apps/worker/src/main.ts: -------------------------------------------------------------------------------- 1 | import { startServer } from './internal/server'; 2 | 3 | async function main() { 4 | try { 5 | const port = process.env.PORT ? parseInt(process.env.PORT) : 3333; 6 | const appEnv = process.env.APP_ENV || 'development'; 7 | await startServer(port, appEnv); 8 | } catch (error) { 9 | console.error('Failed to start server:', error); 10 | process.exit(1); 11 | } 12 | } 13 | 14 | main(); 15 | -------------------------------------------------------------------------------- /apps/worker/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "types": ["node"], 5 | "target": "ES2022", 6 | "module": "preserve" 7 | }, 8 | "files": ["src/main.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import angular from 'angular-eslint'; 4 | import nxPlugin from '@nx/eslint-plugin'; 5 | import prettierConfig from 'eslint-config-prettier'; 6 | 7 | import path from 'node:path'; 8 | import { fileURLToPath } from 'node:url'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | 13 | export default tseslint.config( 14 | { 15 | // Common ignores 16 | ignores: [ 17 | '**/node_modules/**', 18 | '**/dist/**', 19 | '**/tmp/**', 20 | '**/.angular/**', 21 | '**/.nx/cache/**', 22 | // Ignore config files in root? Usually not needed for ESLint >= 8.35.0 23 | // 'eslint.config.js', 24 | // 'prettier.config.js', 25 | // 'nx.json', 26 | // etc. 27 | ], 28 | }, 29 | { 30 | plugins: { 31 | '@nx': nxPlugin, 32 | }, 33 | rules: { 34 | '@nx/enforce-module-boundaries': [ 35 | 'error', 36 | { 37 | enforceBuildableLibDependency: true, 38 | allow: [], 39 | depConstraints: [ 40 | { 41 | sourceTag: '*', 42 | onlyDependOnLibsWithTags: ['*'], 43 | }, 44 | ], 45 | }, 46 | ], 47 | 'no-extra-semi': 'error', // Apply globally from root config 48 | }, 49 | }, 50 | // JavaScript files configuration 51 | { 52 | files: ['**/*.js', '**/*.jsx'], 53 | extends: [eslint.configs.recommended], 54 | rules: { 55 | // Rules specific to JS files if any, besides recommended 56 | }, 57 | }, 58 | // Angular specific configurations (for apps/webapp) 59 | { 60 | files: ['apps/webapp/**/*.ts'], 61 | extends: [ 62 | eslint.configs.recommended, 63 | ...tseslint.configs.recommended, 64 | ...tseslint.configs.stylistic, 65 | ...angular.configs.tsRecommended, 66 | ], 67 | processor: angular.processInlineTemplates, 68 | rules: { 69 | // our project thinks using renaming inputs is ok 70 | '@angular-eslint/no-input-rename': 'off', 71 | }, 72 | }, 73 | // Angular template specific configurations (for apps/webapp) 74 | { 75 | files: ['apps/webapp/**/*.html'], 76 | extends: [ 77 | ...angular.configs.templateRecommended, // Recommended rules for templates 78 | ...angular.configs.templateAccessibility, // Accessibility rules 79 | ], 80 | rules: {}, 81 | }, 82 | // Prettier configuration (must be last to override other style rules) 83 | prettierConfig, 84 | ); 85 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": [ 3 | { 4 | "target": "production", 5 | "public": "dist/apps/webapp/browser", 6 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 7 | "headers": [ 8 | { 9 | "regex": ".+\\.[0-9a-f]{8,}\\.(css|js)$", 10 | "headers": [ 11 | { 12 | "key": "Cache-Control", 13 | "value": "public,max-age=31536000,immutable" 14 | } 15 | ] 16 | } 17 | ], 18 | "rewrites": [ 19 | { 20 | "source": "/api/**", 21 | "run": { 22 | "serviceId": "production-api", 23 | "region": "us-central1" 24 | } 25 | }, 26 | { 27 | "source": "/image", 28 | "run": { 29 | "serviceId": "production-api", 30 | "region": "us-central1" 31 | } 32 | }, 33 | { 34 | "source": "**", 35 | "destination": "/index.html" 36 | } 37 | ] 38 | }, 39 | { 40 | "target": "staging", 41 | "public": "dist/apps/webapp/browser", 42 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 43 | "headers": [ 44 | { 45 | "regex": ".+\\.[0-9a-f]{8,}\\.(css|js)$", 46 | "headers": [ 47 | { 48 | "key": "Cache-Control", 49 | "value": "public,max-age=31536000,immutable" 50 | } 51 | ] 52 | } 53 | ], 54 | "rewrites": [ 55 | { 56 | "source": "/api/**", 57 | "run": { 58 | "serviceId": "staging-api", 59 | "region": "us-central1" 60 | } 61 | }, 62 | { 63 | "source": "/image", 64 | "run": { 65 | "serviceId": "staging-api", 66 | "region": "us-central1" 67 | } 68 | }, 69 | { 70 | "source": "**", 71 | "destination": "/index.html" 72 | } 73 | ] 74 | } 75 | ], 76 | "firestore": { 77 | "rules": "firebase/firestore.rules", 78 | "indexes": "firebase/firestore.indexes.json" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /firebase/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /firebase/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /development/{document=**} { 5 | allow read; 6 | allow write: if false; 7 | } 8 | match /staging/{document=**} { 9 | allow read; 10 | allow write: if false; 11 | } 12 | match /production/{document=**} { 13 | allow read; 14 | allow write: if false; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module contrib.rocks 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | cloud.google.com/go/bigquery v1.64.0 9 | cloud.google.com/go/firestore v1.17.0 10 | cloud.google.com/go/logging v1.12.0 11 | cloud.google.com/go/storage v1.47.0 12 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator v0.49.0 13 | github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b 14 | github.com/andybalholm/brotli v1.1.1 15 | github.com/avast/retry-go/v4 v4.6.1 16 | github.com/bradleyjkemp/cupaloy v2.3.0+incompatible 17 | github.com/gin-gonic/gin v1.10.0 18 | github.com/google/go-github/v69 v69.2.0 19 | go.ajitem.com/zapdriver v1.5.3 20 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 21 | golang.org/x/oauth2 v0.29.0 22 | google.golang.org/api v0.206.0 23 | ) 24 | 25 | require ( 26 | cel.dev/expr v0.16.1 // indirect 27 | cloud.google.com/go/auth v0.10.2 // indirect 28 | cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect 29 | cloud.google.com/go/compute/metadata v0.5.2 // indirect 30 | cloud.google.com/go/longrunning v0.6.2 // indirect 31 | cloud.google.com/go/monitoring v1.21.2 // indirect 32 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.24.1 // indirect 33 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.49.0 // indirect 34 | github.com/apache/arrow/go/v15 v15.0.2 // indirect 35 | github.com/bytedance/sonic v1.11.6 // indirect 36 | github.com/bytedance/sonic/loader v0.1.1 // indirect 37 | github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect 38 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 39 | github.com/cloudwego/base64x v0.1.4 // indirect 40 | github.com/cloudwego/iasm v0.2.0 // indirect 41 | github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect 42 | github.com/envoyproxy/go-control-plane v0.13.0 // indirect 43 | github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect 44 | github.com/felixge/httpsnoop v1.0.4 // indirect 45 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 46 | github.com/google/flatbuffers v23.5.26+incompatible // indirect 47 | github.com/google/s2a-go v0.1.8 // indirect 48 | github.com/klauspost/compress v1.16.7 // indirect 49 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 50 | github.com/pierrec/lz4/v4 v4.1.18 // indirect 51 | github.com/pkg/errors v0.9.1 // indirect 52 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect 53 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 54 | github.com/zeebo/xxh3 v1.0.2 // indirect 55 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 56 | go.opentelemetry.io/contrib/detectors/gcp v1.29.0 // indirect 57 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect 58 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 59 | go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect 60 | golang.org/x/arch v0.8.0 // indirect 61 | golang.org/x/mod v0.24.0 // indirect 62 | golang.org/x/time v0.11.0 // indirect 63 | golang.org/x/tools v0.32.0 // indirect 64 | google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect 65 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect 66 | google.golang.org/grpc/stats/opentelemetry v0.0.0-20240907200651-3ffb98b2c93a // indirect 67 | gopkg.in/yaml.v3 v3.0.1 // indirect 68 | ) 69 | 70 | require ( 71 | cloud.google.com/go v0.116.0 // indirect 72 | cloud.google.com/go/iam v1.2.2 // indirect 73 | cloud.google.com/go/trace v1.11.2 // indirect 74 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.25.0 75 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.49.0 // indirect 76 | github.com/davecgh/go-spew v1.1.1 // indirect 77 | github.com/gin-contrib/sse v0.1.0 // indirect 78 | github.com/go-logr/logr v1.4.2 // indirect 79 | github.com/go-logr/stdr v1.2.2 // indirect 80 | github.com/go-playground/locales v0.14.1 // indirect 81 | github.com/go-playground/universal-translator v0.18.1 // indirect 82 | github.com/go-playground/validator/v10 v10.20.0 // indirect 83 | github.com/goccy/go-json v0.10.2 // indirect 84 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 85 | github.com/google/go-querystring v1.1.0 // indirect 86 | github.com/google/uuid v1.6.0 // indirect 87 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 88 | github.com/googleapis/gax-go/v2 v2.14.0 // indirect 89 | github.com/joho/godotenv v1.5.1 90 | github.com/json-iterator/go v1.1.12 // indirect 91 | github.com/leodido/go-urn v1.4.0 // indirect 92 | github.com/mattn/go-isatty v0.0.20 // indirect 93 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 94 | github.com/modern-go/reflect2 v1.0.2 // indirect 95 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 96 | github.com/pmezard/go-difflib v1.0.0 // indirect 97 | github.com/ugorji/go/codec v1.2.12 // indirect 98 | go.opencensus.io v0.24.0 // indirect 99 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 100 | go.opentelemetry.io/otel v1.35.0 101 | go.opentelemetry.io/otel/bridge/opencensus v1.35.0 102 | go.opentelemetry.io/otel/sdk v1.35.0 103 | go.opentelemetry.io/otel/trace v1.35.0 104 | go.uber.org/multierr v1.11.0 // indirect 105 | go.uber.org/zap v1.27.0 106 | golang.org/x/crypto v0.37.0 // indirect 107 | golang.org/x/net v0.39.0 // indirect 108 | golang.org/x/sync v0.13.0 109 | golang.org/x/sys v0.32.0 // indirect 110 | golang.org/x/text v0.24.0 // indirect 111 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 112 | google.golang.org/genproto v0.0.0-20241104194629-dd2ea8efbc28 // indirect 113 | google.golang.org/grpc v1.67.1 // indirect 114 | google.golang.org/protobuf v1.35.1 // indirect 115 | ) 116 | -------------------------------------------------------------------------------- /migrations.json: -------------------------------------------------------------------------------- 1 | { 2 | "migrations": [ 3 | { 4 | "version": "19.0.0", 5 | "factory": "./use-application-builder/migration", 6 | "description": "Migrate application projects to the new build system. Application projects that are using the '@angular-devkit/build-angular' package's 'browser' and/or 'browser-esbuild' builders will be migrated to use the new 'application' builder. You can read more about this, including known issues and limitations, here: https://angular.dev/tools/cli/build-system-migration", 7 | "optional": true, 8 | "recommended": true, 9 | "documentation": "tools/cli/build-system-migration", 10 | "package": "@angular/cli", 11 | "name": "use-application-builder" 12 | }, 13 | { 14 | "version": "19.0.0", 15 | "factory": "./update-workspace-config/migration", 16 | "description": "Update the workspace configuration by replacing deprecated options in 'angular.json' for compatibility with the latest Angular CLI changes.", 17 | "package": "@angular/cli", 18 | "name": "update-workspace-config" 19 | }, 20 | { 21 | "version": "19.0.0", 22 | "factory": "./update-ssr-imports/migration", 23 | "description": "Update '@angular/ssr' import paths to use the new '/node' entry point when 'CommonEngine' is detected.", 24 | "package": "@angular/cli", 25 | "name": "update-ssr-imports" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "namedInputs": { 4 | "default": [ 5 | "{projectRoot}/**/*", 6 | "{workspaceRoot}/tsconfig.base.json", 7 | "{workspaceRoot}/tsconfig.json", 8 | "{workspaceRoot}/nx.json", 9 | "{workspaceRoot}/package.json", 10 | "{workspaceRoot}/workspace.json", 11 | "{workspaceRoot}/.eslintrc.json" 12 | ] 13 | }, 14 | "tasksRunnerOptions": { 15 | "default": { 16 | "options": {} 17 | } 18 | }, 19 | "targetDefaults": { 20 | "build": { 21 | "dependsOn": ["^build"], 22 | "cache": true 23 | }, 24 | "test": { 25 | "inputs": ["default", "^default"], 26 | "cache": true 27 | }, 28 | "lint": { 29 | "cache": true 30 | } 31 | }, 32 | "generators": { 33 | "@nx/angular:application": { 34 | "unitTestRunner": "karma", 35 | "e2eTestRunner": "none", 36 | "strict": true 37 | }, 38 | "@nx/angular:library": { 39 | "unitTestRunner": "karma", 40 | "strict": true 41 | }, 42 | "@nx/angular:component": { 43 | "style": "scss", 44 | "changeDetection": "OnPush" 45 | }, 46 | "@schematics/angular:component": { 47 | "style": "scss" 48 | } 49 | }, 50 | "parallel": 1, 51 | "defaultBase": "main" 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contributors-img", 3 | "version": "1.1.3", 4 | "scripts": { 5 | "ng": "nx", 6 | "start": "nx serve", 7 | "build": "nx build", 8 | "build:all": "nx run-many --target build --projects webapp --parallel", 9 | "build:all:staging": "pnpm build:all --configuration staging", 10 | "build:all:production": "pnpm build:all --configuration production", 11 | "test": "nx test", 12 | "test:u": "UPDATE_SNAPSHOTS=true pnpm test", 13 | "test:ci": "nx run-many --target test --all --parallel", 14 | "format": "nx run-many --target format --parallel", 15 | "lint": "nx run-many --target lint --parallel", 16 | "nx": "nx", 17 | "affected:apps": "nx affected:apps", 18 | "affected:libs": "nx affected:libs", 19 | "affected:build": "nx affected:build", 20 | "affected:e2e": "nx affected:e2e", 21 | "affected:test": "nx affected:test", 22 | "affected:lint": "nx affected:lint", 23 | "affected:dep-graph": "nx affected:dep-graph", 24 | "affected": "nx affected", 25 | "update": "nx migrate @nx/workspace", 26 | "dep-graph": "nx dep-graph", 27 | "help": "nx help", 28 | "workspace-generator": "nx workspace-generator", 29 | "firebase": "firebase" 30 | }, 31 | "private": true, 32 | "dependencies": { 33 | "@angular/animations": "19.2.2", 34 | "@angular/cdk": "18.2.14", 35 | "@angular/common": "19.2.2", 36 | "@angular/compiler": "19.2.2", 37 | "@angular/core": "19.2.2", 38 | "@angular/fire": "19.0.0", 39 | "@angular/forms": "19.2.2", 40 | "@angular/material": "18.2.14", 41 | "@angular/platform-browser": "19.2.2", 42 | "@angular/platform-browser-dynamic": "19.2.2", 43 | "@angular/router": "19.2.2", 44 | "@rx-angular/state": "19.0.3", 45 | "firebase": "11.6.0", 46 | "normalize.css": "8.0.1", 47 | "rxjs": "7.8.2", 48 | "tslib": "2.8.1", 49 | "zone.js": "0.14.8" 50 | }, 51 | "devDependencies": { 52 | "@angular-devkit/architect": "0.1902.13", 53 | "@angular/build": "^19.2.3", 54 | "@angular/cli": "19.2.13", 55 | "@angular/compiler-cli": "19.2.2", 56 | "@angular/language-service": "19.2.2", 57 | "@eslint/js": "^9.25.1", 58 | "@nx/angular": "20.6.1", 59 | "@nx/eslint": "20.6.1", 60 | "@nx/eslint-plugin": "20.6.1", 61 | "@nx/node": "20.6.1", 62 | "@nx/web": "20.6.1", 63 | "@nx/workspace": "20.6.1", 64 | "@tailwindcss/postcss": "4.1.4", 65 | "@testing-library/angular": "^17.3.6", 66 | "@types/jasmine": "5.1.8", 67 | "@types/node": "22.15.14", 68 | "angular-eslint": "^19.3.0", 69 | "esbuild": "0.25.2", 70 | "eslint": "9.25.1", 71 | "eslint-config-prettier": "8.10.0", 72 | "eslint-plugin-import": "2.31.0", 73 | "eslint-plugin-jsdoc": "50.6.9", 74 | "eslint-plugin-prefer-arrow": "1.2.3", 75 | "firebase-tools": "14.2.0", 76 | "karma-chrome-launcher": "3.2.0", 77 | "karma-coverage": "2.2.1", 78 | "karma-jasmine": "5.1.0", 79 | "karma-jasmine-html-reporter": "2.1.0", 80 | "nx": "20.6.1", 81 | "postcss": "8.5.3", 82 | "prettier": "3.5.3", 83 | "tailwindcss": "4.1.4", 84 | "tsx": "4.19.3", 85 | "typescript": "5.5.4", 86 | "typescript-eslint": "^8.31.0" 87 | }, 88 | "packageManager": "pnpm@10.8.1" 89 | } 90 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'apps/worker' 3 | - 'apps/webapp' 4 | 5 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | printWidth: 120, 4 | trailingComma: 'all', 5 | }; 6 | -------------------------------------------------------------------------------- /tools/tsconfig.tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/out-tsc/tools", 5 | "rootDir": ".", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": ["node"] 9 | }, 10 | "include": ["**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "downlevelIteration": true, 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "module": "es2020", 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "experimentalDecorators": true, 14 | "strict": true, 15 | "strictPropertyInitialization": false, 16 | "noEmitOnError": true, 17 | "importHelpers": true, 18 | "target": "es2015", 19 | "typeRoots": ["node_modules/@types"], 20 | "lib": ["es2018", "dom"], 21 | "skipLibCheck": true, 22 | "paths": {}, 23 | "rootDir": "." 24 | }, 25 | "angularCompilerOptions": { 26 | "fullTemplateTypeCheck": true, 27 | "strictTemplates": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /workspace.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/workspace-schema.json", 3 | "version": 2, 4 | "projects": { 5 | "api": "apps/api", 6 | "webapp": "apps/webapp", 7 | "worker": "apps/worker" 8 | } 9 | } 10 | --------------------------------------------------------------------------------