├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── example-response.json ├── github-response.json ├── index.js ├── license ├── package-lock.json ├── package.json ├── readme.md ├── test.js └── vercel.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 14 14 | - 12 15 | - 10 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | .env 4 | .vercel 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /example-response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "alfred-lock", 4 | "description": "Alfred 3 workflow to lock your Mac", 5 | "url": "https://github.com/sindresorhus/alfred-lock", 6 | "primaryLanguage": null, 7 | "stargazers": 34, 8 | "forks": 0 9 | }, 10 | { 11 | "name": "swift-snippets", 12 | "description": "Various nifty Swift snippets and playgrounds I have created", 13 | "url": "https://github.com/sindresorhus/swift-snippets", 14 | "primaryLanguage": { 15 | "name": "Swift", 16 | "color": "#ffac45" 17 | }, 18 | "stargazers": 34, 19 | "forks": 0 20 | }, 21 | { 22 | "name": "p-progress", 23 | "description": "Create a promise that reports progress", 24 | "url": "https://github.com/sindresorhus/p-progress", 25 | "primaryLanguage": { 26 | "name": "JavaScript", 27 | "color": "#f1e05a" 28 | }, 29 | "stargazers": 648, 30 | "forks": 13 31 | }, 32 | { 33 | "name": "is", 34 | "description": "Type check values: `is.string('🦄') //=> true`", 35 | "url": "https://github.com/sindresorhus/is", 36 | "primaryLanguage": { 37 | "name": "JavaScript", 38 | "color": "#f1e05a" 39 | }, 40 | "stargazers": 295, 41 | "forks": 14 42 | }, 43 | { 44 | "name": "gh-latest-repos", 45 | "description": "Microservice to get the latest public GitHub repos from a user", 46 | "url": "https://github.com/sindresorhus/gh-latest-repos", 47 | "primaryLanguage": { 48 | "name": "JavaScript", 49 | "color": "#f1e05a" 50 | }, 51 | "stargazers": 105, 52 | "forks": 8 53 | }, 54 | { 55 | "name": "strip-debug-cli", 56 | "description": "Strip console, alert, and debugger statements from JavaScript code", 57 | "url": "https://github.com/sindresorhus/strip-debug-cli", 58 | "primaryLanguage": { 59 | "name": "JavaScript", 60 | "color": "#f1e05a" 61 | }, 62 | "stargazers": 22, 63 | "forks": 2 64 | } 65 | ] 66 | -------------------------------------------------------------------------------- /github-response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "node": { 4 | "name": "alfred-lock", 5 | "description": "Alfred 3 workflow to lock your Mac", 6 | "url": "https://github.com/sindresorhus/alfred-lock", 7 | "primaryLanguage": null, 8 | "stargazers": { 9 | "totalCount": 34 10 | }, 11 | "forks": { 12 | "totalCount": 0 13 | } 14 | }, 15 | "cursor": "" 16 | }, 17 | { 18 | "node": { 19 | "name": "swift-snippets", 20 | "description": "Various nifty Swift snippets and playgrounds I have created", 21 | "url": "https://github.com/sindresorhus/swift-snippets", 22 | "primaryLanguage": { 23 | "name": "Swift", 24 | "color": "#ffac45" 25 | }, 26 | "stargazers": { 27 | "totalCount": 34 28 | }, 29 | "forks": { 30 | "totalCount": 0 31 | } 32 | }, 33 | "cursor": "" 34 | }, 35 | { 36 | "node": { 37 | "name": "p-progress", 38 | "description": "Create a promise that reports progress", 39 | "url": "https://github.com/sindresorhus/p-progress", 40 | "primaryLanguage": { 41 | "name": "JavaScript", 42 | "color": "#f1e05a" 43 | }, 44 | "stargazers": { 45 | "totalCount": 648 46 | }, 47 | "forks": { 48 | "totalCount": 13 49 | } 50 | }, 51 | "cursor": "" 52 | }, 53 | { 54 | "node": { 55 | "name": "is", 56 | "description": "Type check values: `is.string('🦄') //=> true`", 57 | "url": "https://github.com/sindresorhus/is", 58 | "primaryLanguage": { 59 | "name": "JavaScript", 60 | "color": "#f1e05a" 61 | }, 62 | "stargazers": { 63 | "totalCount": 295 64 | }, 65 | "forks": { 66 | "totalCount": 14 67 | } 68 | }, 69 | "cursor": "" 70 | }, 71 | { 72 | "node": { 73 | "name": "gh-latest-repos", 74 | "description": "Microservice to get the latest public GitHub repos from a user", 75 | "url": "https://github.com/sindresorhus/gh-latest-repos", 76 | "primaryLanguage": { 77 | "name": "JavaScript", 78 | "color": "#f1e05a" 79 | }, 80 | "stargazers": { 81 | "totalCount": 105 82 | }, 83 | "forks": { 84 | "totalCount": 8 85 | } 86 | }, 87 | "cursor": "" 88 | }, 89 | { 90 | "node": { 91 | "name": "strip-debug-cli", 92 | "description": "", 93 | "url": "https://github.com/sindresorhus/strip-debug-cli", 94 | "primaryLanguage": { 95 | "name": "JavaScript", 96 | "color": "#f1e05a" 97 | }, 98 | "stargazers": { 99 | "totalCount": 22 100 | }, 101 | "forks": { 102 | "totalCount": 2 103 | } 104 | }, 105 | "cursor": "" 106 | }, 107 | { 108 | "node": { 109 | "name": "alfred-lock", 110 | "description": "Alfred 3 workflow to lock your Mac", 111 | "url": "https://github.com/sindresorhus/alfred-lock", 112 | "primaryLanguage": null, 113 | "stargazers": { 114 | "totalCount": 34 115 | }, 116 | "forks": { 117 | "totalCount": 0 118 | } 119 | }, 120 | "cursor": "" 121 | }, 122 | { 123 | "node": { 124 | "name": "swift-snippets", 125 | "description": "Various nifty Swift snippets and playgrounds I have created", 126 | "url": "https://github.com/sindresorhus/swift-snippets", 127 | "primaryLanguage": { 128 | "name": "Swift", 129 | "color": "#ffac45" 130 | }, 131 | "stargazers": { 132 | "totalCount": 34 133 | }, 134 | "forks": { 135 | "totalCount": 0 136 | } 137 | }, 138 | "cursor": "" 139 | }, 140 | { 141 | "node": { 142 | "name": "p-progress", 143 | "description": "Create a promise that reports progress", 144 | "url": "https://github.com/sindresorhus/p-progress", 145 | "primaryLanguage": { 146 | "name": "JavaScript", 147 | "color": "#f1e05a" 148 | }, 149 | "stargazers": { 150 | "totalCount": 648 151 | }, 152 | "forks": { 153 | "totalCount": 13 154 | } 155 | }, 156 | "cursor": "" 157 | }, 158 | { 159 | "node": { 160 | "name": "is", 161 | "description": "Type check values: `is.string('🦄') //=> true`", 162 | "url": "https://github.com/sindresorhus/is", 163 | "primaryLanguage": { 164 | "name": "JavaScript", 165 | "color": "#f1e05a" 166 | }, 167 | "stargazers": { 168 | "totalCount": 295 169 | }, 170 | "forks": { 171 | "totalCount": 14 172 | } 173 | }, 174 | "cursor": "" 175 | }, 176 | { 177 | "node": { 178 | "name": "gh-latest-repos", 179 | "description": "Microservice to get the latest public GitHub repos from a user", 180 | "url": "https://github.com/sindresorhus/gh-latest-repos", 181 | "primaryLanguage": { 182 | "name": "JavaScript", 183 | "color": "#f1e05a" 184 | }, 185 | "stargazers": { 186 | "totalCount": 105 187 | }, 188 | "forks": { 189 | "totalCount": 8 190 | } 191 | }, 192 | "cursor": "" 193 | }, 194 | { 195 | "node": { 196 | "name": "strip-debug-cli", 197 | "description": "Strip console, alert, and debugger statements from JavaScript code", 198 | "url": "https://github.com/sindresorhus/strip-debug-cli", 199 | "primaryLanguage": { 200 | "name": "JavaScript", 201 | "color": "#f1e05a" 202 | }, 203 | "stargazers": { 204 | "totalCount": 22 205 | }, 206 | "forks": { 207 | "totalCount": 2 208 | } 209 | }, 210 | "cursor": "" 211 | } 212 | ] 213 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import graphqlGot from 'graphql-got'; 3 | import controlAccess from 'control-access'; 4 | 5 | const ONE_DAY = 60 * 60 * 24; 6 | 7 | const { 8 | GITHUB_TOKEN, 9 | GITHUB_USERNAME, 10 | ACCESS_ALLOW_ORIGIN, 11 | MAX_REPOS = 6, 12 | } = process.env; 13 | 14 | if (!GITHUB_TOKEN) { 15 | throw new Error('Please set your GitHub token in the `GITHUB_TOKEN` environment variable'); 16 | } 17 | 18 | if (!GITHUB_USERNAME) { 19 | throw new Error('Please set your GitHub username in the `GITHUB_USERNAME` environment variable'); 20 | } 21 | 22 | if (!ACCESS_ALLOW_ORIGIN) { 23 | throw new Error('Please set the `access-control-allow-origin` you want in the `ACCESS_ALLOW_ORIGIN` environment variable'); 24 | } 25 | 26 | const query = ` 27 | query ($cursor: String) { 28 | user(login: "${GITHUB_USERNAME}") { 29 | repositories( 30 | last: ${MAX_REPOS}, 31 | isFork: false, 32 | isLocked: false, 33 | ownerAffiliations: OWNER, 34 | privacy: PUBLIC, 35 | orderBy: { 36 | field: CREATED_AT, 37 | direction: ASC 38 | } 39 | before: $cursor 40 | ) { 41 | edges { 42 | node { 43 | createdAt 44 | name 45 | description 46 | url 47 | primaryLanguage { 48 | name 49 | color 50 | } 51 | stargazers { 52 | totalCount 53 | } 54 | forks { 55 | totalCount 56 | } 57 | } 58 | cursor 59 | } 60 | } 61 | } 62 | } 63 | `; 64 | 65 | const fetchRepos = async (repos = [], cursor = null) => { 66 | const {body} = await graphqlGot('api.github.com/graphql', { 67 | query, 68 | token: GITHUB_TOKEN, 69 | variables: {cursor}, 70 | }); 71 | 72 | const currentRepos = body.user.repositories.edges 73 | .filter(({node: repo}) => repo.description && repo.name !== '.github') 74 | .map(({node: repo}) => ({ 75 | ...repo, 76 | stargazers: repo.stargazers.totalCount, 77 | forks: repo.forks.totalCount, 78 | })); 79 | 80 | if ((repos.length + currentRepos.length) < MAX_REPOS) { 81 | return fetchRepos([...repos, ...currentRepos], body.user.repositories.edges[0].cursor); 82 | } 83 | 84 | return [...repos, ...currentRepos.slice(repos.length - MAX_REPOS)]; 85 | }; 86 | 87 | export default async function main(request, response) { 88 | controlAccess()(request, response); 89 | 90 | try { 91 | const repos = await fetchRepos(); 92 | 93 | repos.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)); 94 | 95 | response.setHeader('content-type', 'application/json'); 96 | response.setHeader('cache-control', `s-maxage=${ONE_DAY}, max-age=0`); 97 | response.end(JSON.stringify(repos)); 98 | } catch (error) { 99 | console.error(error); 100 | 101 | response.statusCode = 500; 102 | response.setHeader('content-type', 'text/plain'); 103 | response.end('Internal server error'); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "engines": { 5 | "node": ">=18" 6 | }, 7 | "scripts": { 8 | "//test": "xo && ava", 9 | "test": "ava", 10 | "start": "GITHUB_USERNAME=sindresorhus ACCESS_ALLOW_ORIGIN='*' vercel dev" 11 | }, 12 | "dependencies": { 13 | "control-access": "^0.1.1", 14 | "graphql-got": "^0.1.2" 15 | }, 16 | "devDependencies": { 17 | "ava": "^5", 18 | "got": "^13.0.0", 19 | "nock": "^13.5.4", 20 | "test-listen": "^1.1.0", 21 | "vercel": "^34.1.5", 22 | "xo": "^0.58.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # gh-latest-repos 2 | 3 | > Microservice to get the latest public GitHub repos from a user 4 | 5 | I currently use this on [my website](https://sindresorhus.com/#projects). 6 | 7 | It returns the latest repos along with some metadata. The result is cached for a day. 8 | 9 | [Example response](example-response.json) 10 | 11 | ## Usage 12 | 13 | ### With [`vercel`](https://vercel.com) 14 | 15 | ```sh 16 | git clone https://github.com/sindresorhus/gh-latest-repos.git 17 | vercel gh-latest-repos --env GITHUB_TOKEN=xxx --env GITHUB_USERNAME=xxx --env ACCESS_ALLOW_ORIGIN=xxx --env MAX_REPOS=xxx 18 | ``` 19 | 20 | ### Manual 21 | 22 | To deploy on your own hosting provider, check out [11e01ac](https://github.com/sindresorhus/gh-latest-repos/commit/11e01acb0d0fd40d69c03155e9862b4cdc71b6f2), set the below environment variables, and start it with `npm start`. 23 | 24 | ## Environment variables 25 | 26 | Define the following environment variables: 27 | 28 | - `GITHUB_TOKEN` - [Personal access token.](https://github.com/settings/tokens/new?description=gh-latest-repos) 29 | - `GITHUB_USERNAME` - The username you like to get repos from. 30 | - `ACCESS_ALLOW_ORIGIN` - The URL of your website or `*` if you want to allow any origin (not recommended), for the `Access-Control-Allow-Origin` header. 31 | - `MAX_REPOS` - The number of repos returned. Optional. Defaults to 6. 32 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import {createServer} from 'node:http'; 3 | import test from 'ava'; 4 | import got from 'got'; 5 | import nock from 'nock'; 6 | import testListen from 'test-listen'; 7 | import fixture from './example-response.json' with {type: 'json'}; 8 | import githubFixture from './github-response.json' with {type: 'json'}; 9 | 10 | const ORIGIN = process.env.ACCESS_ALLOW_ORIGIN; 11 | const TOKEN = process.env.GITHUB_TOKEN; 12 | const USERNAME = process.env.GITHUB_USERNAME; 13 | process.env.CACHE_MAX_AGE = 300; 14 | process.env.MAX_REPOS = 6; 15 | 16 | let url; 17 | 18 | test.before(async () => { 19 | process.env.ACCESS_ALLOW_ORIGIN = '*'; 20 | process.env.GITHUB_TOKEN = 'unicorn'; 21 | process.env.GITHUB_USERNAME = 'sindresorhus'; 22 | 23 | const response = { 24 | data: { 25 | user: { 26 | repositories: { 27 | edges: githubFixture.slice(6), 28 | }, 29 | }, 30 | }, 31 | }; 32 | 33 | const maxReposResponse = { 34 | data: { 35 | user: { 36 | repositories: { 37 | edges: githubFixture.slice(0, 6), 38 | }, 39 | }, 40 | }, 41 | }; 42 | 43 | nock('https://api.github.com/graphql') 44 | .persist() 45 | .filteringPath(pth => `${pth}/`) 46 | .matchHeader('authorization', `bearer ${process.env.GITHUB_TOKEN}`) 47 | .post('/') 48 | .reply(200, response) 49 | .post('/max-repos') 50 | .reply(200, maxReposResponse); 51 | 52 | const {default: main} = await import('./index.js'); 53 | url = await testListen(createServer(main)); 54 | }); 55 | 56 | test.after(() => { 57 | process.env.ACCESS_ALLOW_ORIGIN = ORIGIN; 58 | process.env.GITHUB_TOKEN = TOKEN; 59 | process.env.GITHUB_USERNAME = USERNAME; 60 | }); 61 | 62 | test('fetch latest repos for user', async t => { 63 | const body = await got(url).json(); 64 | t.deepEqual(body, fixture); 65 | if (body.length > 0) { 66 | t.is(typeof body[0].stargazers, 'number'); 67 | t.is(typeof body[0].forks, 'number'); 68 | } 69 | }); 70 | 71 | test('ensure number of repos returned equals `process.env.MAX_REPOS`', async t => { 72 | const body = await got(`${url}/max-repos`).json(); 73 | t.deepEqual(body.length, Number(process.env.MAX_REPOS), `Expected ${process.env.MAX_REPOS}, but got ${body.length}`); 74 | }); 75 | 76 | test('set origin header', async t => { 77 | const {headers} = await got(url, {responseType: 'json'}); 78 | t.is(headers['access-control-allow-origin'], '*'); 79 | t.is(headers['cache-control'], 's-maxage=86400, max-age=0'); 80 | }); 81 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "index.js", 6 | "use": "@now/node" 7 | } 8 | ] 9 | } 10 | --------------------------------------------------------------------------------