From 34de5f2801b154c970160c07f8f2dd76f79390c9 Mon Sep 17 00:00:00 2001 From: KubrickCode Date: Tue, 2 Dec 2025 02:13:11 +0000 Subject: [PATCH 1/2] ifix: fix pnpm install not working in container environment --- .npmrc | 1 + extension/.npmrc | 1 + extension/pnpm-lock.yaml | 133 +++++++++++++++++++++++---------------- 3 files changed, 82 insertions(+), 53 deletions(-) create mode 100644 .npmrc create mode 100644 extension/.npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a079e26 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-import-method=copy diff --git a/extension/.npmrc b/extension/.npmrc new file mode 100644 index 0000000..a079e26 --- /dev/null +++ b/extension/.npmrc @@ -0,0 +1 @@ +package-import-method=copy diff --git a/extension/pnpm-lock.yaml b/extension/pnpm-lock.yaml index 6e8689d..f979138 100644 --- a/extension/pnpm-lock.yaml +++ b/extension/pnpm-lock.yaml @@ -391,8 +391,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.37.0': @@ -628,8 +628,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.46.4': - resolution: {integrity: sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==} + '@typescript-eslint/project-service@8.48.0': + resolution: {integrity: sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -638,8 +638,8 @@ packages: resolution: {integrity: sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.46.4': - resolution: {integrity: sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==} + '@typescript-eslint/scope-manager@8.48.0': + resolution: {integrity: sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.46.0': @@ -648,8 +648,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.46.4': - resolution: {integrity: sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==} + '@typescript-eslint/tsconfig-utils@8.48.0': + resolution: {integrity: sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -665,8 +665,8 @@ packages: resolution: {integrity: sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.46.4': - resolution: {integrity: sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==} + '@typescript-eslint/types@8.48.0': + resolution: {integrity: sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.46.0': @@ -675,8 +675,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.46.4': - resolution: {integrity: sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==} + '@typescript-eslint/typescript-estree@8.48.0': + resolution: {integrity: sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -688,8 +688,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.46.4': - resolution: {integrity: sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==} + '@typescript-eslint/utils@8.48.0': + resolution: {integrity: sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -699,8 +699,8 @@ packages: resolution: {integrity: sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.46.4': - resolution: {integrity: sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==} + '@typescript-eslint/visitor-keys@8.48.0': + resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} abab@2.0.6: @@ -820,8 +820,8 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - baseline-browser-mapping@2.8.29: - resolution: {integrity: sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==} + baseline-browser-mapping@2.8.32: + resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==} hasBin: true brace-expansion@1.1.12: @@ -873,8 +873,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001755: - resolution: {integrity: sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==} + caniuse-lite@1.0.30001757: + resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1022,8 +1022,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.255: - resolution: {integrity: sha512-Z9oIp4HrFF/cZkDPMpz2XSuVpc1THDpT4dlmATFlJUIBVCy9Vap5/rIXsASP1CscBacBqhabwh8vLctqBwEerQ==} + electron-to-chromium@1.5.262: + resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -1207,6 +1207,15 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1933,6 +1942,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -2170,6 +2183,10 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -2678,7 +2695,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 debug: 4.4.3 @@ -3046,10 +3063,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.4(typescript@5.3.3)': + '@typescript-eslint/project-service@8.48.0(typescript@5.3.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.3.3) - '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.3.3) + '@typescript-eslint/types': 8.48.0 debug: 4.4.3 typescript: 5.3.3 transitivePeerDependencies: @@ -3060,16 +3077,16 @@ snapshots: '@typescript-eslint/types': 8.46.0 '@typescript-eslint/visitor-keys': 8.46.0 - '@typescript-eslint/scope-manager@8.46.4': + '@typescript-eslint/scope-manager@8.48.0': dependencies: - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/visitor-keys': 8.46.4 + '@typescript-eslint/types': 8.48.0 + '@typescript-eslint/visitor-keys': 8.48.0 '@typescript-eslint/tsconfig-utils@8.46.0(typescript@5.3.3)': dependencies: typescript: 5.3.3 - '@typescript-eslint/tsconfig-utils@8.46.4(typescript@5.3.3)': + '@typescript-eslint/tsconfig-utils@8.48.0(typescript@5.3.3)': dependencies: typescript: 5.3.3 @@ -3087,7 +3104,7 @@ snapshots: '@typescript-eslint/types@8.46.0': {} - '@typescript-eslint/types@8.46.4': {} + '@typescript-eslint/types@8.48.0': {} '@typescript-eslint/typescript-estree@8.46.0(typescript@5.3.3)': dependencies: @@ -3105,17 +3122,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.46.4(typescript@5.3.3)': + '@typescript-eslint/typescript-estree@8.48.0(typescript@5.3.3)': dependencies: - '@typescript-eslint/project-service': 8.46.4(typescript@5.3.3) - '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.3.3) - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/visitor-keys': 8.46.4 + '@typescript-eslint/project-service': 8.48.0(typescript@5.3.3) + '@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.3.3) + '@typescript-eslint/types': 8.48.0 + '@typescript-eslint/visitor-keys': 8.48.0 debug: 4.4.3 - fast-glob: 3.3.3 - is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.7.3 + tinyglobby: 0.2.15 ts-api-utils: 2.1.0(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: @@ -3132,12 +3148,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.4(eslint@9.37.0)(typescript@5.3.3)': + '@typescript-eslint/utils@8.48.0(eslint@9.37.0)(typescript@5.3.3)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0) - '@typescript-eslint/scope-manager': 8.46.4 - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.3.3) + '@typescript-eslint/scope-manager': 8.48.0 + '@typescript-eslint/types': 8.48.0 + '@typescript-eslint/typescript-estree': 8.48.0(typescript@5.3.3) eslint: 9.37.0 typescript: 5.3.3 transitivePeerDependencies: @@ -3148,9 +3164,9 @@ snapshots: '@typescript-eslint/types': 8.46.0 eslint-visitor-keys: 4.2.1 - '@typescript-eslint/visitor-keys@8.46.4': + '@typescript-eslint/visitor-keys@8.48.0': dependencies: - '@typescript-eslint/types': 8.46.4 + '@typescript-eslint/types': 8.48.0 eslint-visitor-keys: 4.2.1 abab@2.0.6: {} @@ -3321,7 +3337,7 @@ snapshots: balanced-match@1.0.2: {} - baseline-browser-mapping@2.8.29: {} + baseline-browser-mapping@2.8.32: {} brace-expansion@1.1.12: dependencies: @@ -3338,9 +3354,9 @@ snapshots: browserslist@4.28.0: dependencies: - baseline-browser-mapping: 2.8.29 - caniuse-lite: 1.0.30001755 - electron-to-chromium: 1.5.255 + baseline-browser-mapping: 2.8.32 + caniuse-lite: 1.0.30001757 + electron-to-chromium: 1.5.262 node-releases: 2.0.27 update-browserslist-db: 1.1.4(browserslist@4.28.0) @@ -3377,7 +3393,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001755: {} + caniuse-lite@1.0.30001757: {} chalk@4.1.2: dependencies: @@ -3515,7 +3531,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.255: {} + electron-to-chromium@1.5.262: {} emittery@0.13.1: {} @@ -3701,8 +3717,8 @@ snapshots: eslint-plugin-perfectionist@4.15.1(eslint@9.37.0)(typescript@5.3.3): dependencies: - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/utils': 8.46.4(eslint@9.37.0)(typescript@5.3.3) + '@typescript-eslint/types': 8.48.0 + '@typescript-eslint/utils': 8.48.0(eslint@9.37.0)(typescript@5.3.3) eslint: 9.37.0 natural-orderby: 5.0.0 transitivePeerDependencies: @@ -3725,7 +3741,7 @@ snapshots: '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.16.0 - '@eslint/eslintrc': 3.3.1 + '@eslint/eslintrc': 3.3.3 '@eslint/js': 9.37.0 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -3822,6 +3838,10 @@ snapshots: dependencies: bser: 2.1.1 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -4754,6 +4774,8 @@ snapshots: picomatch@2.3.1: {} + picomatch@4.0.3: {} + pirates@4.0.7: {} pkg-dir@4.2.0: @@ -5008,6 +5030,11 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + tmpl@1.0.5: {} to-regex-range@5.0.1: From b48d70f03fe80f1d846de6a24698c590407fd988 Mon Sep 17 00:00:00 2001 From: KubrickCode Date: Tue, 2 Dec 2025 02:32:45 +0000 Subject: [PATCH 2/2] refactor: move GitHub GraphQL API logic to server Changed architecture from Extension directly calling GitHub GraphQL API to routing through server for better separation of concerns - Add /api/issues/status and /api/issues/status/update endpoints to server - Simplify Extension's github-api.service.ts - Move GraphQL query building/parsing logic to Go server - Improve security by removing direct GitHub token exposure in Extension fix #81 --- extension/src/constants/api.spec.ts | 12 +- extension/src/constants/api.ts | 6 - .../src/services/github-api.service.spec.ts | 601 ++++-------------- extension/src/services/github-api.service.ts | 378 ++--------- server/api/issues/status/index.go | 68 ++ server/api/issues/status/index_test.go | 231 +++++++ server/api/issues/status/update/index.go | 65 ++ server/api/issues/status/update/index_test.go | 204 ++++++ server/pkg/auth/github_token.go | 53 ++ server/pkg/github/client.go | 323 ++++++++++ server/pkg/github/client_test.go | 443 +++++++++++++ server/pkg/github/types.go | 99 +++ 12 files changed, 1667 insertions(+), 816 deletions(-) create mode 100644 server/api/issues/status/index.go create mode 100644 server/api/issues/status/index_test.go create mode 100644 server/api/issues/status/update/index.go create mode 100644 server/api/issues/status/update/index_test.go create mode 100644 server/pkg/auth/github_token.go create mode 100644 server/pkg/github/client.go create mode 100644 server/pkg/github/client_test.go create mode 100644 server/pkg/github/types.go diff --git a/extension/src/constants/api.spec.ts b/extension/src/constants/api.spec.ts index 1824abb..762c4bc 100644 --- a/extension/src/constants/api.spec.ts +++ b/extension/src/constants/api.spec.ts @@ -1,11 +1,10 @@ -import { API, ERROR_MESSAGES, GRAPHQL } from "./api"; +import { API, ERROR_MESSAGES } from "./api"; describe("API Constants", () => { describe("API", () => { it("should have valid URL formats", () => { const urlPattern = /^https?:\/\/.+/; expect(API.BASE_URL).toMatch(urlPattern); - expect(API.GITHUB.GRAPHQL_URL).toMatch(urlPattern); expect(API.GITHUB.OAUTH_URL).toMatch(urlPattern); }); @@ -22,14 +21,6 @@ describe("API Constants", () => { it("should have correct structure", () => { expect(API.BASE_URL).toBeDefined(); expect(API.GITHUB).toBeDefined(); - expect(API.GITHUB.GRAPHQL_URL).toBeDefined(); - }); - }); - - describe("GRAPHQL", () => { - it("should have valid field names", () => { - expect(GRAPHQL.ISSUE_ALIAS_PREFIX).toBe("issue"); - expect(GRAPHQL.STATUS_FIELD_NAME).toBe("Status"); }); }); @@ -40,7 +31,6 @@ describe("API Constants", () => { }); it("should have correct structure", () => { - // Verify the error message exists and is a string expect(typeof ERROR_MESSAGES.AUTH_REQUIRED).toBe("string"); expect(ERROR_MESSAGES.AUTH_REQUIRED.length).toBeGreaterThan(0); }); diff --git a/extension/src/constants/api.ts b/extension/src/constants/api.ts index c92795a..8cd7c13 100644 --- a/extension/src/constants/api.ts +++ b/extension/src/constants/api.ts @@ -2,7 +2,6 @@ export const API = { BASE_URL: "https://github-project-status-viewer.vercel.app/api", GITHUB: { CLIENT_ID: "Ov23liFFkeCk13ofhM7c", - GRAPHQL_URL: "https://api.github.com/graphql", OAUTH_URL: "https://github.com/login/oauth/authorize", SCOPE: "repo project", }, @@ -11,8 +10,3 @@ export const API = { export const ERROR_MESSAGES = { AUTH_REQUIRED: "Authentication required. Please log in via the extension popup.", } as const; - -export const GRAPHQL = { - ISSUE_ALIAS_PREFIX: "issue", - STATUS_FIELD_NAME: "Status", -} as const; diff --git a/extension/src/services/github-api.service.spec.ts b/extension/src/services/github-api.service.spec.ts index 3d32b1e..c83657d 100644 --- a/extension/src/services/github-api.service.spec.ts +++ b/extension/src/services/github-api.service.spec.ts @@ -1,12 +1,6 @@ -import { API, GRAPHQL } from "../constants/api"; +import { API } from "../constants/api"; import { STORAGE_KEYS } from "../constants/storage"; -import { - buildProjectStatusQuery, - fetchProjectStatus, - getGithubAccessToken, - refreshTokens, - updateProjectStatus, -} from "./github-api.service"; +import { fetchProjectStatus, refreshTokens, updateProjectStatus } from "./github-api.service"; const mockChromeStorage = { session: { @@ -22,92 +16,35 @@ Object.assign(globalThis, { globalThis.fetch = jest.fn() as jest.Mock; +const createMockResponse = (options: { + json?: () => Promise; + ok: boolean; + status?: number; + text?: () => Promise; +}) => ({ + json: options.json ?? (async () => ({})), + ok: options.ok, + status: options.status ?? (options.ok ? 200 : 500), + text: options.text ?? (async () => ""), +}); + describe("github-api.service", () => { beforeEach(() => { jest.clearAllMocks(); }); - describe("buildProjectStatusQuery", () => { - it("단일 이슈 번호에 대한 GraphQL 쿼리 생성", () => { - const query = buildProjectStatusQuery([123]); - - expect(query).toContain("query($owner: String!, $name: String!)"); - expect(query).toContain(`${GRAPHQL.ISSUE_ALIAS_PREFIX}0: issue(number: 123)`); - expect(query).toContain("projectItems(first: 10)"); - expect(query).toContain("fieldValues(first: 20)"); - }); - - it("여러 이슈 번호에 대한 GraphQL 쿼리 생성", () => { - const query = buildProjectStatusQuery([1, 2, 3]); - - expect(query).toContain(`${GRAPHQL.ISSUE_ALIAS_PREFIX}0: issue(number: 1)`); - expect(query).toContain(`${GRAPHQL.ISSUE_ALIAS_PREFIX}1: issue(number: 2)`); - expect(query).toContain(`${GRAPHQL.ISSUE_ALIAS_PREFIX}2: issue(number: 3)`); - }); - - it("빈 배열에 대한 쿼리 생성", () => { - const query = buildProjectStatusQuery([]); - - expect(query).toContain("query($owner: String!, $name: String!)"); - expect(query).toContain("repository(owner: $owner, name: $name)"); - }); - }); - - describe("getGithubAccessToken", () => { - it("유효한 토큰으로 GitHub 액세스 토큰 가져오기", async () => { - const mockAccessToken = "mock_github_token"; - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - json: async () => ({ access_token: mockAccessToken }), - ok: true, - }); - - const result = await getGithubAccessToken("valid_token"); - - expect(result).toBe(mockAccessToken); - expect(globalThis.fetch).toHaveBeenCalledWith(`${API.BASE_URL}/verify`, { - headers: { - Authorization: "Bearer valid_token", - "Content-Type": "application/json", - }, - method: "POST", - }); - }); - - it("토큰 검증 실패 시 status를 포함한 에러 발생", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 401, - }); - - await expect(getGithubAccessToken("invalid_token")).rejects.toThrow( - "Token verification failed: 401" - ); - }); - - it("토큰 검증 실패 시 status 프로퍼티 포함", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 403, - }); - - try { - await getGithubAccessToken("forbidden_token"); - } catch (error) { - expect((error as { status?: number }).status).toBe(403); - } - }); - }); - describe("refreshTokens", () => { it("리프레시 토큰으로 새 토큰 획득", async () => { const mockTokens = { access_token: "new_access_token", refresh_token: "new_refresh_token", }; - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - json: async () => mockTokens, - ok: true, - }); + (globalThis.fetch as jest.Mock).mockResolvedValueOnce( + createMockResponse({ + json: async () => mockTokens, + ok: true, + }) + ); const result = await refreshTokens("valid_refresh_token"); @@ -125,10 +62,12 @@ describe("github-api.service", () => { }); it("토큰 갱신 실패 시 에러 발생", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 401, - }); + (globalThis.fetch as jest.Mock).mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 401, + }) + ); await expect(refreshTokens("invalid_refresh_token")).rejects.toThrow( "Token refresh failed: 401" @@ -137,60 +76,8 @@ describe("github-api.service", () => { }); describe("fetchProjectStatus", () => { - const mockGraphQLResponse = { - data: { - repository: { - issue0: { - number: 1, - projectItems: { - nodes: [ - { - fieldValues: { - nodes: [ - { - color: "GREEN", - field: { - id: "field-123", - name: "Status", - options: [ - { color: "GREEN", id: "opt-1", name: "Done" }, - { color: "YELLOW", id: "opt-2", name: "In Progress" }, - ], - }, - name: "Done", - }, - ], - }, - id: "item-123", - project: { id: "project-123" }, - }, - ], - }, - }, - }, - }, - }; - - it("프로젝트 상태 조회 성공", async () => { - (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), - ok: true, - }) - .mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify(mockGraphQLResponse), - }); - - const result = await fetchProjectStatus({ - accessToken: "access_token", - issueNumbers: [1], - owner: "owner", - refreshToken: "refresh_token", - repo: "repo", - }); - - expect(result).toEqual([ + const mockStatusResponse = { + statuses: [ { color: "GREEN", number: 1, @@ -203,266 +90,82 @@ describe("github-api.service", () => { { color: "YELLOW", id: "opt-2", name: "In Progress" }, ], }, - ]); - }); + ], + }; - it("should refresh token and retry when token is expired", async () => { - (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - ok: false, - status: 401, - }) - .mockResolvedValueOnce({ - json: async () => ({ - access_token: "new_access_token", - refresh_token: "new_refresh_token", - }), - ok: true, - }) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), + it("프로젝트 상태 조회 성공", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce( + createMockResponse({ + json: async () => mockStatusResponse, ok: true, }) - .mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify(mockGraphQLResponse), - }); + ); const result = await fetchProjectStatus({ - accessToken: "expired_token", + accessToken: "access_token", issueNumbers: [1], owner: "owner", refreshToken: "refresh_token", repo: "repo", }); - expect(mockChromeStorage.session.set).toHaveBeenCalledWith({ - [STORAGE_KEYS.ACCESS_TOKEN]: "new_access_token", - [STORAGE_KEYS.REFRESH_TOKEN]: "new_refresh_token", - }); - expect(result).toEqual([ - { - color: "GREEN", - number: 1, - projectId: "project-123", - projectItemId: "item-123", - status: "Done", - statusFieldId: "field-123", - statusOptions: [ - { color: "GREEN", id: "opt-1", name: "Done" }, - { color: "YELLOW", id: "opt-2", name: "In Progress" }, - ], - }, - ]); - }); - - it("should throw error when GraphQL returns error", async () => { - (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), - ok: true, - }) - .mockResolvedValueOnce({ - ok: true, - text: async () => - JSON.stringify({ - errors: [{ message: "Field 'repository' not found", type: "NOT_FOUND" }], - }), - }); - - await expect( - fetchProjectStatus({ - accessToken: "access_token", - issueNumbers: [1], - owner: "owner", - refreshToken: "refresh_token", - repo: "repo", - }) - ).rejects.toThrow("GraphQL error"); - }); - - it("저장소를 찾을 수 없을 때 에러 처리", async () => { - (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), - ok: true, - }) - .mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify({ data: {} }), - }); - - await expect( - fetchProjectStatus({ - accessToken: "access_token", + expect(result).toEqual(mockStatusResponse.statuses); + expect(globalThis.fetch).toHaveBeenCalledWith(`${API.BASE_URL}/issues/status`, { + body: JSON.stringify({ issueNumbers: [1], owner: "owner", - refreshToken: "refresh_token", repo: "repo", - }) - ).rejects.toThrow("Repository not found"); - }); - - it("상태가 없는 이슈에 대해 null 반환", async () => { - const responseWithoutStatus = { - data: { - repository: { - issue0: { - number: 1, - projectItems: { - nodes: [], - }, - }, - }, + }), + headers: { + Authorization: "Bearer access_token", + "Content-Type": "application/json", }, - }; - - (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), - ok: true, - }) - .mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify(responseWithoutStatus), - }); - - const result = await fetchProjectStatus({ - accessToken: "access_token", - issueNumbers: [1], - owner: "owner", - refreshToken: "refresh_token", - repo: "repo", + method: "POST", }); - - expect(result).toEqual([ - { - color: null, - number: 1, - projectId: null, - projectItemId: null, - status: null, - statusFieldId: null, - statusOptions: null, - }, - ]); }); - it("여러 이슈 상태 조회", async () => { - const multipleIssuesResponse = { - data: { - repository: { - issue0: { - number: 1, - projectItems: { - nodes: [ - { - fieldValues: { - nodes: [ - { - color: "GREEN", - field: { id: "field-1", name: "Status", options: [] }, - name: "Done", - }, - ], - }, - id: "item-1", - project: { id: "proj-1" }, - }, - ], - }, - }, - issue1: { - number: 2, - projectItems: { - nodes: [ - { - fieldValues: { - nodes: [ - { - color: "YELLOW", - field: { id: "field-2", name: "Status", options: [] }, - name: "In Progress", - }, - ], - }, - id: "item-2", - project: { id: "proj-2" }, - }, - ], - }, - }, - }, - }, - }; - + it("토큰 만료 시 갱신 후 재시도", async () => { (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), - ok: true, - }) - .mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify(multipleIssuesResponse), - }); + .mockResolvedValueOnce(createMockResponse({ ok: false, status: 401 })) + .mockResolvedValueOnce( + createMockResponse({ + json: async () => ({ + access_token: "new_access_token", + refresh_token: "new_refresh_token", + }), + ok: true, + }) + ) + .mockResolvedValueOnce( + createMockResponse({ + json: async () => mockStatusResponse, + ok: true, + }) + ); const result = await fetchProjectStatus({ - accessToken: "access_token", - issueNumbers: [1, 2], + accessToken: "expired_token", + issueNumbers: [1], owner: "owner", refreshToken: "refresh_token", repo: "repo", }); - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - color: "GREEN", - number: 1, - projectId: "proj-1", - projectItemId: "item-1", - status: "Done", - statusFieldId: "field-1", - statusOptions: [], - }); - expect(result[1]).toEqual({ - color: "YELLOW", - number: 2, - projectId: "proj-2", - projectItemId: "item-2", - status: "In Progress", - statusFieldId: "field-2", - statusOptions: [], + expect(mockChromeStorage.session.set).toHaveBeenCalledWith({ + [STORAGE_KEYS.ACCESS_TOKEN]: "new_access_token", + [STORAGE_KEYS.REFRESH_TOKEN]: "new_refresh_token", }); + expect(result).toEqual(mockStatusResponse.statuses); }); - it("should throw error when GitHub API returns error", async () => { - (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), - ok: true, - }) - .mockResolvedValueOnce({ + it("API 에러 시 에러 발생", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce( + createMockResponse({ ok: false, status: 500, text: async () => "Internal Server Error", - }); - - await expect( - fetchProjectStatus({ - accessToken: "access_token", - issueNumbers: [1], - owner: "owner", - refreshToken: "refresh_token", - repo: "repo", }) - ).rejects.toThrow("GitHub API error: 500"); - }); - - it("토큰 갱신 외 다른 에러는 그대로 전파", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 500, - }); + ); await expect( fetchProjectStatus({ @@ -472,39 +175,23 @@ describe("github-api.service", () => { refreshToken: "refresh_token", repo: "repo", }) - ).rejects.toThrow("Token verification failed: 500"); + ).rejects.toThrow("API error: 500"); }); }); describe("updateProjectStatus", () => { - const mockMutationResponse = { - data: { - updateProjectV2ItemFieldValue: { - projectV2Item: { - fieldValues: { - nodes: [ - { - color: "GREEN", - field: { name: "Status" }, - name: "Done", - }, - ], - }, - }, - }, - }, + const mockUpdateResponse = { + color: "GREEN", + status: "Done", }; - it("should update status successfully", async () => { - (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), + it("상태 업데이트 성공", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce( + createMockResponse({ + json: async () => mockUpdateResponse, ok: true, }) - .mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify(mockMutationResponse), - }); + ); const result = await updateProjectStatus({ accessToken: "access_token", @@ -515,13 +202,23 @@ describe("github-api.service", () => { refreshToken: "refresh_token", }); - expect(result).toEqual({ - color: "GREEN", - status: "Done", + expect(result).toEqual(mockUpdateResponse); + expect(globalThis.fetch).toHaveBeenCalledWith(`${API.BASE_URL}/issues/status/update`, { + body: JSON.stringify({ + fieldId: "field-123", + itemId: "item-123", + optionId: "opt-1", + projectId: "project-123", + }), + headers: { + Authorization: "Bearer access_token", + "Content-Type": "application/json", + }, + method: "POST", }); }); - it("should throw error when required parameters are missing", async () => { + it("필수 파라미터 누락 시 에러 발생", async () => { await expect( updateProjectStatus({ accessToken: "access_token", @@ -534,27 +231,24 @@ describe("github-api.service", () => { ).rejects.toThrow("Missing required parameters for updateProjectStatus"); }); - it("should refresh token and retry when token is expired", async () => { + it("토큰 만료 시 갱신 후 재시도", async () => { (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - ok: false, - status: 401, - }) - .mockResolvedValueOnce({ - json: async () => ({ - access_token: "new_access_token", - refresh_token: "new_refresh_token", - }), - ok: true, - }) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), - ok: true, - }) - .mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify(mockMutationResponse), - }); + .mockResolvedValueOnce(createMockResponse({ ok: false, status: 401 })) + .mockResolvedValueOnce( + createMockResponse({ + json: async () => ({ + access_token: "new_access_token", + refresh_token: "new_refresh_token", + }), + ok: true, + }) + ) + .mockResolvedValueOnce( + createMockResponse({ + json: async () => mockUpdateResponse, + ok: true, + }) + ); const result = await updateProjectStatus({ accessToken: "expired_token", @@ -572,75 +266,14 @@ describe("github-api.service", () => { expect(result.status).toBe("Done"); }); - it("should throw error when GraphQL returns error", async () => { - (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), - ok: true, - }) - .mockResolvedValueOnce({ - ok: true, - text: async () => - JSON.stringify({ - errors: [{ message: "Insufficient permissions", type: "FORBIDDEN" }], - }), - }); - - await expect( - updateProjectStatus({ - accessToken: "access_token", - fieldId: "field-123", - itemId: "item-123", - optionId: "opt-1", - projectId: "project-123", - refreshToken: "refresh_token", - }) - ).rejects.toThrow("GraphQL error"); - }); - - it("should throw error when updated status is not found", async () => { - (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), - ok: true, - }) - .mockResolvedValueOnce({ - ok: true, - text: async () => - JSON.stringify({ - data: { - updateProjectV2ItemFieldValue: { - projectV2Item: { - fieldValues: { nodes: [] }, - }, - }, - }, - }), - }); - - await expect( - updateProjectStatus({ - accessToken: "access_token", - fieldId: "field-123", - itemId: "item-123", - optionId: "opt-1", - projectId: "project-123", - refreshToken: "refresh_token", - }) - ).rejects.toThrow("Failed to get updated status"); - }); - - it("should throw error when GitHub API returns error", async () => { - (globalThis.fetch as jest.Mock) - .mockResolvedValueOnce({ - json: async () => ({ access_token: "github_token" }), - ok: true, - }) - .mockResolvedValueOnce({ + it("API 에러 시 에러 발생", async () => { + (globalThis.fetch as jest.Mock).mockResolvedValueOnce( + createMockResponse({ ok: false, status: 500, text: async () => "Internal Server Error", - }); + }) + ); await expect( updateProjectStatus({ @@ -651,7 +284,7 @@ describe("github-api.service", () => { projectId: "project-123", refreshToken: "refresh_token", }) - ).rejects.toThrow("GitHub API error: 500"); + ).rejects.toThrow("API error: 500"); }); }); }); diff --git a/extension/src/services/github-api.service.ts b/extension/src/services/github-api.service.ts index 68b985a..a76aad2 100644 --- a/extension/src/services/github-api.service.ts +++ b/extension/src/services/github-api.service.ts @@ -1,32 +1,8 @@ -import { API, GRAPHQL } from "../constants/api"; +import { API } from "../constants/api"; import { STORAGE_KEYS } from "../constants/storage"; -import { IssueStatus, StatusOption } from "../shared/types"; +import { IssueStatus } from "../shared/types"; const HTTP_STATUS_UNAUTHORIZED = 401; -const GRAPHQL_PROJECT_ITEMS_LIMIT = 10; -const GRAPHQL_FIELD_VALUES_LIMIT = 20; - -const UPDATE_PROJECT_STATUS_MUTATION = ` - mutation($input: UpdateProjectV2ItemFieldValueInput!) { - updateProjectV2ItemFieldValue(input: $input) { - projectV2Item { - fieldValues(first: ${GRAPHQL_FIELD_VALUES_LIMIT}) { - nodes { - ... on ProjectV2ItemFieldSingleSelectValue { - name - color - field { - ... on ProjectV2SingleSelectField { - name - } - } - } - } - } - } - } - } -`; type FetchProjectStatusParams = { accessToken: string; @@ -36,53 +12,14 @@ type FetchProjectStatusParams = { repo: string; }; -type FieldValueNode = { - color?: string; - field?: { - id?: string; - name: string; - options?: Array<{ - color: string; - id: string; - name: string; - }>; - }; - name?: string; -}; - -type GraphQLResponse = { - data?: { - repository?: { - [key: string]: IssueNode; - }; - }; - errors?: Array<{ - message: string; - type?: string; - }>; -}; - -type IssueNode = { - number: number; - projectItems: { - nodes: Array<{ - fieldValues: { - nodes: FieldValueNode[]; - }; - id: string; - project: { - id: string; - }; - }>; - }; -}; - type RefreshResponse = { access_token: string; refresh_token: string; }; -type TokenError = Error & { status?: number }; +type StatusResponse = { + statuses: IssueStatus[]; +}; type UpdateStatusParams = { accessToken: string; @@ -94,23 +31,8 @@ type UpdateStatusParams = { }; type UpdateStatusResponse = { - data?: { - updateProjectV2ItemFieldValue?: { - projectV2Item?: { - fieldValues: { - nodes: FieldValueNode[]; - }; - }; - }; - }; - errors?: Array<{ - message: string; - type?: string; - }>; -}; - -type VerifyResponse = { - access_token: string; + color: string; + status: string; }; export const fetchProjectStatus = async ({ @@ -120,124 +42,37 @@ export const fetchProjectStatus = async ({ refreshToken, repo, }: FetchProjectStatusParams): Promise => { - const query = buildProjectStatusQuery(issueNumbers); - const githubAccessToken = await getValidGithubAccessToken(accessToken, refreshToken); - - const response = await fetch(API.GITHUB.GRAPHQL_URL, { - body: JSON.stringify({ - query, - variables: { - name: repo, - owner, - }, - }), - headers: { - Authorization: `Bearer ${githubAccessToken}`, - "Content-Type": "application/json", - }, - method: "POST", - }); - - const responseText = await response.text(); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status} - ${responseText}`); - } - - const data: GraphQLResponse = JSON.parse(responseText); - - if (data.errors) { - throw new Error(`GraphQL error: ${JSON.stringify(data.errors)}`); - } - - if (!data.data?.repository) { - throw new Error("Repository not found"); - } - - const issues: IssueNode[] = Object.entries(data.data.repository) - .filter(([key]) => key.startsWith(GRAPHQL.ISSUE_ALIAS_PREFIX)) - .map(([, issue]) => issue); - - const issueStatusMap = buildIssueStatusMap(issues); - - return issueNumbers.map((number) => { - const statusData = issueStatusMap.get(number); - return { - color: statusData?.color || null, - number, - projectId: statusData?.projectId || null, - projectItemId: statusData?.projectItemId || null, - status: statusData?.status || null, - statusFieldId: statusData?.statusFieldId || null, - statusOptions: statusData?.statusOptions || null, - }; + const data = await withAuth({ + accessToken, + body: { issueNumbers, owner, repo }, + refreshToken, + url: `${API.BASE_URL}/issues/status`, }); + return data.statuses; }; -export const buildProjectStatusQuery = (issueNumbers: number[]): string => { - const issueQueries = issueNumbers - .map( - (num, index) => ` - ${GRAPHQL.ISSUE_ALIAS_PREFIX}${index}: issue(number: ${num}) { - number - projectItems(first: ${GRAPHQL_PROJECT_ITEMS_LIMIT}) { - nodes { - id - project { - id - } - fieldValues(first: ${GRAPHQL_FIELD_VALUES_LIMIT}) { - nodes { - ... on ProjectV2ItemFieldSingleSelectValue { - name - color - field { - ... on ProjectV2SingleSelectField { - id - name - options { - id - name - color - } - } - } - } - } - } - } - } - } - ` - ) - .join("\n"); - - return ` - query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - ${issueQueries} - } - } - `; -}; - -export const getGithubAccessToken = async (accessToken: string): Promise => { - const response = await fetch(`${API.BASE_URL}/verify`, { - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - method: "POST", - }); - - if (!response.ok) { - const error: TokenError = new Error(`Token verification failed: ${response.status}`); - error.status = response.status; - throw error; +export const updateProjectStatus = async ({ + accessToken, + fieldId, + itemId, + optionId, + projectId, + refreshToken, +}: UpdateStatusParams): Promise<{ color: string; status: string }> => { + if (!accessToken || !fieldId || !itemId || !optionId || !projectId || !refreshToken) { + throw new Error("Missing required parameters for updateProjectStatus"); } - const data: VerifyResponse = await response.json(); - return data.access_token; + const data = await withAuth({ + accessToken, + body: { fieldId, itemId, optionId, projectId }, + refreshToken, + url: `${API.BASE_URL}/issues/status/update`, + }); + return { + color: data.color, + status: data.status, + }; }; export const refreshTokens = async ( @@ -262,132 +97,45 @@ export const refreshTokens = async ( }; }; -type IssueStatusData = { - color: string | null; - projectId: string | null; - projectItemId: string | null; - status: string; - statusFieldId: string | null; - statusOptions: StatusOption[] | null; -}; - -const buildIssueStatusMap = (issues: IssueNode[]): Map => { - const issueStatusMap = new Map(); - - issues.forEach((issue) => { - if (!issue.number || !issue.projectItems.nodes.length) return; - - const firstProjectItem = issue.projectItems.nodes[0]; - const statusField = firstProjectItem.fieldValues.nodes.find( - (node) => node.field?.name === GRAPHQL.STATUS_FIELD_NAME && node.name - ); - - if (statusField?.name) { - const options: StatusOption[] | null = statusField.field?.options - ? statusField.field.options.map((opt) => ({ - color: opt.color, - id: opt.id, - name: opt.name, - })) - : null; - - issueStatusMap.set(issue.number, { - color: statusField.color || null, - projectId: firstProjectItem.project?.id || null, - projectItemId: firstProjectItem.id || null, - status: statusField.name, - statusFieldId: statusField.field?.id || null, - statusOptions: options, - }); - } - }); - - return issueStatusMap; -}; - -const isTokenError = (error: unknown): error is TokenError => { - return error instanceof Error && "status" in error && typeof error.status === "number"; -}; - -const getValidGithubAccessToken = async ( - accessToken: string, - refreshToken: string -): Promise => { - try { - return await getGithubAccessToken(accessToken); - } catch (error) { - if (isTokenError(error) && error.status === HTTP_STATUS_UNAUTHORIZED) { - const tokens = await refreshTokens(refreshToken); - - await chrome.storage.session.set({ - [STORAGE_KEYS.ACCESS_TOKEN]: tokens.accessToken, - [STORAGE_KEYS.REFRESH_TOKEN]: tokens.refreshToken, - }); - - return await getGithubAccessToken(tokens.accessToken); - } - throw error; - } +type WithAuthParams = { + accessToken: string; + body: object; + refreshToken: string; + url: string; }; -export const updateProjectStatus = async ({ +const withAuth = async ({ accessToken, - fieldId, - itemId, - optionId, - projectId, + body, refreshToken, -}: UpdateStatusParams): Promise<{ color: string; status: string }> => { - if (!accessToken || !fieldId || !itemId || !optionId || !projectId || !refreshToken) { - throw new Error("Missing required parameters for updateProjectStatus"); - } - - const githubAccessToken = await getValidGithubAccessToken(accessToken, refreshToken); - - const response = await fetch(API.GITHUB.GRAPHQL_URL, { - body: JSON.stringify({ - query: UPDATE_PROJECT_STATUS_MUTATION, - variables: { - input: { - fieldId, - itemId, - projectId, - value: { - singleSelectOptionId: optionId, - }, - }, + url, +}: WithAuthParams): Promise => { + const makeRequest = async (token: string) => { + return fetch(url, { + body: JSON.stringify(body), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, - }), - headers: { - Authorization: `Bearer ${githubAccessToken}`, - "Content-Type": "application/json", - }, - method: "POST", - }); - - const responseText = await response.text(); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status} - ${responseText}`); - } + method: "POST", + }); + }; - const data: UpdateStatusResponse = JSON.parse(responseText); + let response = await makeRequest(accessToken); - if (data.errors) { - throw new Error(`GraphQL error: ${JSON.stringify(data.errors)}`); + if (response.status === HTTP_STATUS_UNAUTHORIZED) { + const tokens = await refreshTokens(refreshToken); + await chrome.storage.session.set({ + [STORAGE_KEYS.ACCESS_TOKEN]: tokens.accessToken, + [STORAGE_KEYS.REFRESH_TOKEN]: tokens.refreshToken, + }); + response = await makeRequest(tokens.accessToken); } - const fieldValues = data.data?.updateProjectV2ItemFieldValue?.projectV2Item?.fieldValues.nodes; - const statusField = fieldValues?.find( - (node) => node.field?.name === GRAPHQL.STATUS_FIELD_NAME && node.name - ); - - if (!statusField?.name) { - throw new Error("Failed to get updated status"); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API error: ${response.status} - ${errorText}`); } - return { - color: statusField.color || "", - status: statusField.name, - }; + return response.json() as Promise; }; diff --git a/server/api/issues/status/index.go b/server/api/issues/status/index.go new file mode 100644 index 0000000..797c617 --- /dev/null +++ b/server/api/issues/status/index.go @@ -0,0 +1,68 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + + "github-project-status-viewer-server/pkg/auth" + "github-project-status-viewer-server/pkg/github" + "github-project-status-viewer-server/pkg/httputil" + "github-project-status-viewer-server/pkg/oauth" +) + +const maxIssueNumbers = 100 + +type StatusRequest struct { + IssueNumbers []int `json:"issueNumbers"` + Owner string `json:"owner"` + Repo string `json:"repo"` +} + +type StatusResponse struct { + Statuses []github.IssueStatus `json:"statuses"` +} + +func Handler(w http.ResponseWriter, r *http.Request) { + oauth.SetCORS(w) + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + if !httputil.EnsureMethod(w, r, http.MethodPost) { + return + } + + githubToken, err := auth.ExtractGitHubToken(r) + if err != nil { + auth.HandleTokenError(w, err) + return + } + + var req StatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httputil.WriteError(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + if req.Owner == "" || req.Repo == "" || len(req.IssueNumbers) == 0 { + httputil.WriteError(w, http.StatusBadRequest, "invalid_request", "owner, repo, and issueNumbers are required") + return + } + + if len(req.IssueNumbers) > maxIssueNumbers { + httputil.WriteError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("maximum %d issues allowed per request", maxIssueNumbers)) + return + } + + client := github.NewClient(githubToken) + statuses, err := client.FetchProjectStatus(r.Context(), req.Owner, req.Repo, req.IssueNumbers) + if err != nil { + httputil.WriteErrorWithLog(w, err, http.StatusBadGateway, "github_error", "Failed to fetch project status") + return + } + + httputil.JSON(w, http.StatusOK, StatusResponse{Statuses: statuses}) +} diff --git a/server/api/issues/status/index_test.go b/server/api/issues/status/index_test.go new file mode 100644 index 0000000..f886386 --- /dev/null +++ b/server/api/issues/status/index_test.go @@ -0,0 +1,231 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github-project-status-viewer-server/pkg/httputil" + "github-project-status-viewer-server/pkg/jwt" +) + +func generateTestToken(t *testing.T) string { + t.Helper() + t.Setenv("JWT_SECRET", "test-secret-key-for-testing") + manager, err := jwt.NewManager() + if err != nil { + t.Fatalf("failed to create JWT manager: %v", err) + } + token, err := manager.GenerateAccessToken("test-session-id") + if err != nil { + t.Fatalf("failed to generate access token: %v", err) + } + return token +} + +func TestHandler_MethodValidation(t *testing.T) { + tests := []struct { + method string + name string + wantStatus int + }{ + { + name: "POST method should be accepted", + method: http.MethodPost, + wantStatus: http.StatusUnauthorized, + }, + { + name: "GET method should be rejected", + method: http.MethodGet, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "PUT method should be rejected", + method: http.MethodPut, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "DELETE method should be rejected", + method: http.MethodDelete, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "OPTIONS method should be accepted for CORS", + method: http.MethodOptions, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, "/api/issues/status", nil) + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("Status code = %v, want %v", w.Code, tt.wantStatus) + } + }) + } +} + +func TestHandler_CORSHeaders(t *testing.T) { + t.Setenv("CHROME_EXTENSION_ID", "test-extension-id") + + req := httptest.NewRequest(http.MethodOptions, "/api/issues/status", nil) + w := httptest.NewRecorder() + + Handler(w, req) + + corsHeader := w.Header().Get("Access-Control-Allow-Origin") + expectedOrigin := "chrome-extension://test-extension-id" + if corsHeader != expectedOrigin { + t.Errorf("Expected CORS header to be %s, got %s", expectedOrigin, corsHeader) + } +} + +func TestHandler_InvalidRequestBody(t *testing.T) { + t.Setenv("JWT_SECRET", "test-secret") + + req := httptest.NewRequest(http.MethodPost, "/api/issues/status", bytes.NewReader([]byte("invalid json"))) + req.Header.Set("Authorization", "Bearer invalid-token") + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Status code = %v, want %v", w.Code, http.StatusUnauthorized) + } +} + +func TestHandler_MissingRequiredFields(t *testing.T) { + tests := []struct { + expectedDesc string + name string + requestBody StatusRequest + }{ + { + name: "missing owner", + requestBody: StatusRequest{Owner: "", Repo: "repo", IssueNumbers: []int{1}}, + expectedDesc: "owner, repo, and issueNumbers are required", + }, + { + name: "missing repo", + requestBody: StatusRequest{Owner: "owner", Repo: "", IssueNumbers: []int{1}}, + expectedDesc: "owner, repo, and issueNumbers are required", + }, + { + name: "missing issueNumbers", + requestBody: StatusRequest{Owner: "owner", Repo: "repo", IssueNumbers: []int{}}, + expectedDesc: "owner, repo, and issueNumbers are required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := generateTestToken(t) + body, _ := json.Marshal(tt.requestBody) + req := httptest.NewRequest(http.MethodPost, "/api/issues/status", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Status code = %v, want %v", w.Code, http.StatusBadRequest) + } + + var apiError httputil.APIError + if err := json.NewDecoder(w.Body).Decode(&apiError); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + if apiError.Description != tt.expectedDesc { + t.Errorf("Expected error description %q, got %q", tt.expectedDesc, apiError.Description) + } + }) + } +} + +func TestHandler_ExceedsMaxIssueNumbers(t *testing.T) { + token := generateTestToken(t) + issueNumbers := make([]int, 101) + for i := range issueNumbers { + issueNumbers[i] = i + 1 + } + + requestBody := StatusRequest{ + Owner: "owner", + Repo: "repo", + IssueNumbers: issueNumbers, + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest(http.MethodPost, "/api/issues/status", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Status code = %v, want %v", w.Code, http.StatusBadRequest) + } + + var apiError httputil.APIError + if err := json.NewDecoder(w.Body).Decode(&apiError); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + if apiError.Code != "invalid_request" { + t.Errorf("Expected error code 'invalid_request', got %q", apiError.Code) + } +} + +func TestHandler_MissingAuthorizationHeader(t *testing.T) { + requestBody := StatusRequest{ + Owner: "owner", + Repo: "repo", + IssueNumbers: []int{1}, + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest(http.MethodPost, "/api/issues/status", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Status code = %v, want %v", w.Code, http.StatusUnauthorized) + } + + var apiError httputil.APIError + json.NewDecoder(w.Body).Decode(&apiError) + + if apiError.Code != "invalid_token" { + t.Errorf("Expected error code 'invalid_token', got %q", apiError.Code) + } +} + +func TestHandler_InvalidAuthorizationHeader(t *testing.T) { + requestBody := StatusRequest{ + Owner: "owner", + Repo: "repo", + IssueNumbers: []int{1}, + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest(http.MethodPost, "/api/issues/status", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic invalid") + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Status code = %v, want %v", w.Code, http.StatusUnauthorized) + } +} diff --git a/server/api/issues/status/update/index.go b/server/api/issues/status/update/index.go new file mode 100644 index 0000000..21ec230 --- /dev/null +++ b/server/api/issues/status/update/index.go @@ -0,0 +1,65 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github-project-status-viewer-server/pkg/auth" + "github-project-status-viewer-server/pkg/github" + "github-project-status-viewer-server/pkg/httputil" + "github-project-status-viewer-server/pkg/oauth" +) + +type UpdateRequest struct { + FieldID string `json:"fieldId"` + ItemID string `json:"itemId"` + OptionID string `json:"optionId"` + ProjectID string `json:"projectId"` +} + +type UpdateResponse struct { + Color string `json:"color"` + Status string `json:"status"` +} + +func Handler(w http.ResponseWriter, r *http.Request) { + oauth.SetCORS(w) + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + if !httputil.EnsureMethod(w, r, http.MethodPost) { + return + } + + githubToken, err := auth.ExtractGitHubToken(r) + if err != nil { + auth.HandleTokenError(w, err) + return + } + + var req UpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httputil.WriteError(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + if req.ProjectID == "" || req.ItemID == "" || req.FieldID == "" || req.OptionID == "" { + httputil.WriteError(w, http.StatusBadRequest, "invalid_request", "projectId, itemId, fieldId, and optionId are required") + return + } + + client := github.NewClient(githubToken) + result, err := client.UpdateProjectStatus(r.Context(), req.ProjectID, req.ItemID, req.FieldID, req.OptionID) + if err != nil { + httputil.WriteErrorWithLog(w, err, http.StatusBadGateway, "github_error", "Failed to update project status") + return + } + + httputil.JSON(w, http.StatusOK, UpdateResponse{ + Color: result.Color, + Status: result.Status, + }) +} diff --git a/server/api/issues/status/update/index_test.go b/server/api/issues/status/update/index_test.go new file mode 100644 index 0000000..3b7548c --- /dev/null +++ b/server/api/issues/status/update/index_test.go @@ -0,0 +1,204 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github-project-status-viewer-server/pkg/httputil" + "github-project-status-viewer-server/pkg/jwt" +) + +func generateTestToken(t *testing.T) string { + t.Helper() + t.Setenv("JWT_SECRET", "test-secret-key-for-testing") + manager, err := jwt.NewManager() + if err != nil { + t.Fatalf("failed to create JWT manager: %v", err) + } + token, err := manager.GenerateAccessToken("test-session-id") + if err != nil { + t.Fatalf("failed to generate access token: %v", err) + } + return token +} + +func TestHandler_MethodValidation(t *testing.T) { + tests := []struct { + method string + name string + wantStatus int + }{ + { + name: "POST method should be accepted", + method: http.MethodPost, + wantStatus: http.StatusUnauthorized, + }, + { + name: "GET method should be rejected", + method: http.MethodGet, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "PUT method should be rejected", + method: http.MethodPut, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "DELETE method should be rejected", + method: http.MethodDelete, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "OPTIONS method should be accepted for CORS", + method: http.MethodOptions, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, "/api/issues/status/update", nil) + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("Status code = %v, want %v", w.Code, tt.wantStatus) + } + }) + } +} + +func TestHandler_CORSHeaders(t *testing.T) { + t.Setenv("CHROME_EXTENSION_ID", "test-extension-id") + + req := httptest.NewRequest(http.MethodOptions, "/api/issues/status/update", nil) + w := httptest.NewRecorder() + + Handler(w, req) + + corsHeader := w.Header().Get("Access-Control-Allow-Origin") + expectedOrigin := "chrome-extension://test-extension-id" + if corsHeader != expectedOrigin { + t.Errorf("Expected CORS header to be %s, got %s", expectedOrigin, corsHeader) + } +} + +func TestHandler_InvalidRequestBody(t *testing.T) { + t.Setenv("JWT_SECRET", "test-secret") + + req := httptest.NewRequest(http.MethodPost, "/api/issues/status/update", bytes.NewReader([]byte("invalid json"))) + req.Header.Set("Authorization", "Bearer invalid-token") + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Status code = %v, want %v", w.Code, http.StatusUnauthorized) + } +} + +func TestHandler_MissingRequiredFields(t *testing.T) { + tests := []struct { + expectedDesc string + name string + requestBody UpdateRequest + }{ + { + name: "missing projectId", + requestBody: UpdateRequest{ProjectID: "", ItemID: "item", FieldID: "field", OptionID: "option"}, + expectedDesc: "projectId, itemId, fieldId, and optionId are required", + }, + { + name: "missing itemId", + requestBody: UpdateRequest{ProjectID: "project", ItemID: "", FieldID: "field", OptionID: "option"}, + expectedDesc: "projectId, itemId, fieldId, and optionId are required", + }, + { + name: "missing fieldId", + requestBody: UpdateRequest{ProjectID: "project", ItemID: "item", FieldID: "", OptionID: "option"}, + expectedDesc: "projectId, itemId, fieldId, and optionId are required", + }, + { + name: "missing optionId", + requestBody: UpdateRequest{ProjectID: "project", ItemID: "item", FieldID: "field", OptionID: ""}, + expectedDesc: "projectId, itemId, fieldId, and optionId are required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := generateTestToken(t) + body, _ := json.Marshal(tt.requestBody) + req := httptest.NewRequest(http.MethodPost, "/api/issues/status/update", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Status code = %v, want %v", w.Code, http.StatusBadRequest) + } + + var apiError httputil.APIError + if err := json.NewDecoder(w.Body).Decode(&apiError); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + if apiError.Description != tt.expectedDesc { + t.Errorf("Expected error description %q, got %q", tt.expectedDesc, apiError.Description) + } + }) + } +} + +func TestHandler_MissingAuthorizationHeader(t *testing.T) { + requestBody := UpdateRequest{ + ProjectID: "project-123", + ItemID: "item-123", + FieldID: "field-123", + OptionID: "option-123", + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest(http.MethodPost, "/api/issues/status/update", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Status code = %v, want %v", w.Code, http.StatusUnauthorized) + } + + var apiError httputil.APIError + json.NewDecoder(w.Body).Decode(&apiError) + + if apiError.Code != "invalid_token" { + t.Errorf("Expected error code 'invalid_token', got %q", apiError.Code) + } +} + +func TestHandler_InvalidAuthorizationHeader(t *testing.T) { + requestBody := UpdateRequest{ + ProjectID: "project-123", + ItemID: "item-123", + FieldID: "field-123", + OptionID: "option-123", + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest(http.MethodPost, "/api/issues/status/update", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic invalid") + w := httptest.NewRecorder() + + Handler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Status code = %v, want %v", w.Code, http.StatusUnauthorized) + } +} diff --git a/server/pkg/auth/github_token.go b/server/pkg/auth/github_token.go new file mode 100644 index 0000000..83c8e2a --- /dev/null +++ b/server/pkg/auth/github_token.go @@ -0,0 +1,53 @@ +package auth + +import ( + "errors" + "net/http" + "strings" + + pkgerrors "github-project-status-viewer-server/pkg/errors" + "github-project-status-viewer-server/pkg/httputil" + "github-project-status-viewer-server/pkg/jwt" + "github-project-status-viewer-server/pkg/redis" +) + +const bearerPrefix = "Bearer " + +func ExtractGitHubToken(r *http.Request) (string, error) { + tokenString := r.Header.Get("Authorization") + if !strings.HasPrefix(tokenString, bearerPrefix) { + return "", pkgerrors.ErrBearerTokenRequired + } + + accessToken := strings.TrimPrefix(tokenString, bearerPrefix) + claims, err := jwt.ValidateAccessToken(accessToken) + if err != nil { + return "", err + } + + redisClient, err := redis.GetClient() + if err != nil { + return "", err + } + + githubToken, err := redisClient.Get(redis.SessionKeyPrefix + claims.SessionID) + if err != nil { + if errors.Is(err, pkgerrors.ErrKeyNotFound) { + return "", pkgerrors.ErrSessionNotFound + } + return "", err + } + + return githubToken, nil +} + +func HandleTokenError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, pkgerrors.ErrBearerTokenRequired): + httputil.WriteError(w, http.StatusUnauthorized, "invalid_token", "Bearer token required") + case errors.Is(err, pkgerrors.ErrSessionNotFound): + httputil.WriteErrorWithLog(w, err, http.StatusUnauthorized, "session_not_found", "Session expired or invalid") + default: + httputil.WriteErrorWithLog(w, err, http.StatusUnauthorized, "invalid_access_token", "Invalid or expired access token") + } +} diff --git a/server/pkg/github/client.go b/server/pkg/github/client.go new file mode 100644 index 0000000..a659b65 --- /dev/null +++ b/server/pkg/github/client.go @@ -0,0 +1,323 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + defaultGraphQLURL = "https://api.github.com/graphql" + defaultTimeout = 30 * time.Second + issueAliasPrefix = "issue" + projectItemsLimit = 10 + fieldValuesLimit = 20 + statusFieldName = "Status" +) + +type Client struct { + accessToken string + graphQLURL string + httpClient *http.Client +} + +func NewClient(accessToken string) *Client { + return &Client{ + accessToken: accessToken, + graphQLURL: defaultGraphQLURL, + httpClient: &http.Client{Timeout: defaultTimeout}, + } +} + +func NewClientWithURL(accessToken, graphQLURL string) *Client { + return &Client{ + accessToken: accessToken, + graphQLURL: graphQLURL, + httpClient: &http.Client{Timeout: defaultTimeout}, + } +} + +func (c *Client) FetchProjectStatus(ctx context.Context, owner, repo string, issueNumbers []int) ([]IssueStatus, error) { + query := buildProjectStatusQuery(issueNumbers) + + reqBody := graphQLRequest{ + Query: query, + Variables: map[string]any{ + "owner": owner, + "name": repo, + }, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphQLURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, string(respBody)) + } + + var gqlResp graphQLResponse + if err := json.Unmarshal(respBody, &gqlResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if len(gqlResp.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", gqlResp.Errors[0].Message) + } + + if gqlResp.Data == nil { + return nil, fmt.Errorf("repository not found") + } + + return buildIssueStatusList(gqlResp.Data.Repository, issueNumbers), nil +} + +func (c *Client) UpdateProjectStatus(ctx context.Context, projectID, itemID, fieldID, optionID string) (*UpdateStatusResult, error) { + query := buildUpdateStatusMutation() + + reqBody := graphQLRequest{ + Query: query, + Variables: map[string]any{ + "input": map[string]any{ + "projectId": projectID, + "itemId": itemID, + "fieldId": fieldID, + "value": map[string]any{ + "singleSelectOptionId": optionID, + }, + }, + }, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphQLURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, string(respBody)) + } + + var gqlResp updateStatusResponse + if err := json.Unmarshal(respBody, &gqlResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if len(gqlResp.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", gqlResp.Errors[0].Message) + } + + if gqlResp.Data == nil || gqlResp.Data.UpdateProjectV2ItemFieldValue == nil { + return nil, fmt.Errorf("failed to update status") + } + + fieldValues := gqlResp.Data.UpdateProjectV2ItemFieldValue.ProjectV2Item.FieldValues.Nodes + for _, node := range fieldValues { + if node.Field != nil && node.Field.Name == statusFieldName && node.Name != nil { + color := "" + if node.Color != nil { + color = *node.Color + } + return &UpdateStatusResult{ + Color: color, + Status: *node.Name, + }, nil + } + } + + return nil, fmt.Errorf("failed to get updated status") +} + +func buildProjectStatusQuery(issueNumbers []int) string { + var issueQueries strings.Builder + + for i, num := range issueNumbers { + fmt.Fprintf(&issueQueries, ` + %s%d: issue(number: %d) { + number + projectItems(first: %d) { + nodes { + id + project { + id + } + fieldValues(first: %d) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + color + field { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + color + } + } + } + } + } + } + } + } + }`, issueAliasPrefix, i, num, projectItemsLimit, fieldValuesLimit) + } + + return fmt.Sprintf(` + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + %s + } + } + `, issueQueries.String()) +} + +func buildUpdateStatusMutation() string { + return fmt.Sprintf(` + mutation($input: UpdateProjectV2ItemFieldValueInput!) { + updateProjectV2ItemFieldValue(input: $input) { + projectV2Item { + fieldValues(first: %d) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + color + field { + ... on ProjectV2SingleSelectField { + name + } + } + } + } + } + } + } + } + `, fieldValuesLimit) +} + +type issueStatusData struct { + color *string + projectID *string + projectItemID *string + status *string + statusFieldID *string + statusOptions []StatusOption +} + +func findStatusField(item projectItemNode) *issueStatusData { + for _, node := range item.FieldValues.Nodes { + if node.Field == nil || node.Field.Name != statusFieldName || node.Name == nil { + continue + } + + var options []StatusOption + if node.Field.Options != nil { + options = make([]StatusOption, len(node.Field.Options)) + for i, opt := range node.Field.Options { + options[i] = StatusOption{ + Color: opt.Color, + ID: opt.ID, + Name: opt.Name, + } + } + } + + projectID := item.Project.ID + itemID := item.ID + fieldID := node.Field.ID + + return &issueStatusData{ + color: node.Color, + projectID: &projectID, + projectItemID: &itemID, + status: node.Name, + statusFieldID: &fieldID, + statusOptions: options, + } + } + + return nil +} + +func buildIssueStatusList(repository map[string]issueNode, issueNumbers []int) []IssueStatus { + statusMap := make(map[int]*issueStatusData) + + for key, issue := range repository { + if !strings.HasPrefix(key, issueAliasPrefix) { + continue + } + + if issue.Number == 0 || len(issue.ProjectItems.Nodes) == 0 { + continue + } + + firstProjectItem := issue.ProjectItems.Nodes[0] + statusData := findStatusField(firstProjectItem) + if statusData != nil { + statusMap[issue.Number] = statusData + } + } + + result := make([]IssueStatus, len(issueNumbers)) + for i, num := range issueNumbers { + result[i] = IssueStatus{Number: num} + + if data, ok := statusMap[num]; ok { + result[i].Color = data.color + result[i].ProjectID = data.projectID + result[i].ProjectItemID = data.projectItemID + result[i].Status = data.status + result[i].StatusFieldID = data.statusFieldID + result[i].StatusOptions = data.statusOptions + } + } + + return result +} diff --git a/server/pkg/github/client_test.go b/server/pkg/github/client_test.go new file mode 100644 index 0000000..84547f3 --- /dev/null +++ b/server/pkg/github/client_test.go @@ -0,0 +1,443 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestBuildProjectStatusQuery(t *testing.T) { + tests := []struct { + issueNumbers []int + name string + wantContains []string + }{ + { + name: "single issue", + issueNumbers: []int{123}, + wantContains: []string{ + "issue0: issue(number: 123)", + "projectItems(first: 10)", + "fieldValues(first: 20)", + }, + }, + { + name: "multiple issues", + issueNumbers: []int{1, 2, 3}, + wantContains: []string{ + "issue0: issue(number: 1)", + "issue1: issue(number: 2)", + "issue2: issue(number: 3)", + }, + }, + { + name: "empty array", + issueNumbers: []int{}, + wantContains: []string{ + "query($owner: String!, $name: String!)", + "repository(owner: $owner, name: $name)", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query := buildProjectStatusQuery(tt.issueNumbers) + + for _, want := range tt.wantContains { + if !strings.Contains(query, want) { + t.Errorf("query should contain %q, got:\n%s", want, query) + } + } + }) + } +} + +func TestBuildUpdateStatusMutation(t *testing.T) { + mutation := buildUpdateStatusMutation() + + wantContains := []string{ + "mutation($input: UpdateProjectV2ItemFieldValueInput!)", + "updateProjectV2ItemFieldValue(input: $input)", + "projectV2Item", + "fieldValues(first: 20)", + } + + for _, want := range wantContains { + if !strings.Contains(mutation, want) { + t.Errorf("mutation should contain %q, got:\n%s", want, mutation) + } + } +} + +func TestFetchProjectStatus_Success(t *testing.T) { + mockResponse := graphQLResponse{ + Data: &repositoryData{ + Repository: map[string]issueNode{ + "issue0": { + Number: 1, + ProjectItems: projectItems{ + Nodes: []projectItemNode{ + { + ID: "item-123", + Project: project{ + ID: "project-123", + }, + FieldValues: fieldValues{ + Nodes: []fieldValueNode{ + { + Name: strPtr("Done"), + Color: strPtr("GREEN"), + Field: &fieldDetail{ + ID: "field-123", + Name: "Status", + Options: []statusOption{ + {Color: "GREEN", ID: "opt-1", Name: "Done"}, + {Color: "YELLOW", ID: "opt-2", Name: "In Progress"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization")) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := NewClientWithURL("test-token", server.URL) + + statuses, err := client.FetchProjectStatus(context.Background(), "owner", "repo", []int{1}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(statuses) != 1 { + t.Fatalf("expected 1 status, got %d", len(statuses)) + } + + status := statuses[0] + if status.Number != 1 { + t.Errorf("expected number 1, got %d", status.Number) + } + if *status.Status != "Done" { + t.Errorf("expected status Done, got %s", *status.Status) + } + if *status.Color != "GREEN" { + t.Errorf("expected color GREEN, got %s", *status.Color) + } + if *status.ProjectID != "project-123" { + t.Errorf("expected projectId project-123, got %s", *status.ProjectID) + } +} + +func TestFetchProjectStatus_GraphQLError(t *testing.T) { + mockResponse := graphQLResponse{ + Errors: []graphQLError{ + {Message: "Not Found", Type: "NOT_FOUND"}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := NewClientWithURL("test-token", server.URL) + + _, err := client.FetchProjectStatus(context.Background(), "owner", "repo", []int{1}) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "GraphQL error") { + t.Errorf("expected GraphQL error, got: %v", err) + } +} + +func TestFetchProjectStatus_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Unauthorized")) + })) + defer server.Close() + + client := NewClientWithURL("invalid-token", server.URL) + + _, err := client.FetchProjectStatus(context.Background(), "owner", "repo", []int{1}) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "401") { + t.Errorf("expected 401 error, got: %v", err) + } +} + +func TestFetchProjectStatus_NilRepository(t *testing.T) { + mockResponse := graphQLResponse{ + Data: nil, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := NewClientWithURL("test-token", server.URL) + + _, err := client.FetchProjectStatus(context.Background(), "owner", "repo", []int{1}) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "repository not found") { + t.Errorf("expected repository not found error, got: %v", err) + } +} + +func TestFetchProjectStatus_IssueWithoutProject(t *testing.T) { + mockResponse := graphQLResponse{ + Data: &repositoryData{ + Repository: map[string]issueNode{ + "issue0": { + Number: 1, + ProjectItems: projectItems{ + Nodes: []projectItemNode{}, + }, + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := NewClientWithURL("test-token", server.URL) + + statuses, err := client.FetchProjectStatus(context.Background(), "owner", "repo", []int{1}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(statuses) != 1 { + t.Fatalf("expected 1 status, got %d", len(statuses)) + } + + if statuses[0].Status != nil { + t.Errorf("expected nil status, got %v", statuses[0].Status) + } +} + +func TestUpdateProjectStatus_Success(t *testing.T) { + mockResponse := updateStatusResponse{ + Data: &updateData{ + UpdateProjectV2ItemFieldValue: &updateResult{ + ProjectV2Item: &projectV2Item{ + FieldValues: fieldValues{ + Nodes: []fieldValueNode{ + { + Name: strPtr("In Progress"), + Color: strPtr("YELLOW"), + Field: &fieldDetail{ + Name: "Status", + }, + }, + }, + }, + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := NewClientWithURL("test-token", server.URL) + + result, err := client.UpdateProjectStatus(context.Background(), "proj-1", "item-1", "field-1", "opt-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Status != "In Progress" { + t.Errorf("expected status In Progress, got %s", result.Status) + } + if result.Color != "YELLOW" { + t.Errorf("expected color YELLOW, got %s", result.Color) + } +} + +func TestUpdateProjectStatus_GraphQLError(t *testing.T) { + mockResponse := updateStatusResponse{ + Errors: []graphQLError{ + {Message: "Insufficient permissions"}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := NewClientWithURL("test-token", server.URL) + + _, err := client.UpdateProjectStatus(context.Background(), "proj-1", "item-1", "field-1", "opt-1") + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "GraphQL error") { + t.Errorf("expected GraphQL error, got: %v", err) + } +} + +func TestUpdateProjectStatus_NoStatusFieldReturned(t *testing.T) { + mockResponse := updateStatusResponse{ + Data: &updateData{ + UpdateProjectV2ItemFieldValue: &updateResult{ + ProjectV2Item: &projectV2Item{ + FieldValues: fieldValues{ + Nodes: []fieldValueNode{}, + }, + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := NewClientWithURL("test-token", server.URL) + + _, err := client.UpdateProjectStatus(context.Background(), "proj-1", "item-1", "field-1", "opt-1") + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "failed to get updated status") { + t.Errorf("expected failed to get updated status error, got: %v", err) + } +} + +func TestBuildIssueStatusList(t *testing.T) { + repository := map[string]issueNode{ + "issue0": { + Number: 1, + ProjectItems: projectItems{ + Nodes: []projectItemNode{ + { + ID: "item-1", + Project: project{ID: "proj-1"}, + FieldValues: fieldValues{ + Nodes: []fieldValueNode{ + { + Name: strPtr("Done"), + Color: strPtr("GREEN"), + Field: &fieldDetail{ + ID: "field-1", + Name: "Status", + Options: []statusOption{ + {ID: "opt-1", Name: "Done", Color: "GREEN"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "issue1": { + Number: 2, + ProjectItems: projectItems{Nodes: []projectItemNode{}}, + }, + } + + result := buildIssueStatusList(repository, []int{1, 2, 3}) + + if len(result) != 3 { + t.Fatalf("expected 3 results, got %d", len(result)) + } + + if result[0].Number != 1 { + t.Errorf("expected number 1, got %d", result[0].Number) + } + if *result[0].Status != "Done" { + t.Errorf("expected status Done, got %s", *result[0].Status) + } + + if result[1].Number != 2 { + t.Errorf("expected number 2, got %d", result[1].Number) + } + if result[1].Status != nil { + t.Errorf("expected nil status for issue 2, got %v", result[1].Status) + } + + if result[2].Number != 3 { + t.Errorf("expected number 3, got %d", result[2].Number) + } + if result[2].Status != nil { + t.Errorf("expected nil status for issue 3, got %v", result[2].Status) + } +} + +func TestNewClient(t *testing.T) { + client := NewClient("test-token") + + if client.accessToken != "test-token" { + t.Errorf("expected accessToken test-token, got %s", client.accessToken) + } + + if client.graphQLURL != defaultGraphQLURL { + t.Errorf("expected graphQLURL %s, got %s", defaultGraphQLURL, client.graphQLURL) + } + + if client.httpClient == nil { + t.Error("expected httpClient to be initialized") + } +} + +func TestNewClientWithURL(t *testing.T) { + customURL := "https://custom.example.com/graphql" + client := NewClientWithURL("test-token", customURL) + + if client.accessToken != "test-token" { + t.Errorf("expected accessToken test-token, got %s", client.accessToken) + } + + if client.graphQLURL != customURL { + t.Errorf("expected graphQLURL %s, got %s", customURL, client.graphQLURL) + } +} + +func strPtr(s string) *string { + return &s +} diff --git a/server/pkg/github/types.go b/server/pkg/github/types.go new file mode 100644 index 0000000..cb7b137 --- /dev/null +++ b/server/pkg/github/types.go @@ -0,0 +1,99 @@ +package github + +type IssueStatus struct { + Color *string `json:"color"` + Number int `json:"number"` + ProjectID *string `json:"projectId"` + ProjectItemID *string `json:"projectItemId"` + Status *string `json:"status"` + StatusFieldID *string `json:"statusFieldId"` + StatusOptions []StatusOption `json:"statusOptions"` +} + +type StatusOption struct { + Color string `json:"color"` + ID string `json:"id"` + Name string `json:"name"` +} + +type UpdateStatusResult struct { + Color string `json:"color"` + Status string `json:"status"` +} + +type graphQLRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` +} + +type graphQLResponse struct { + Data *repositoryData `json:"data"` + Errors []graphQLError `json:"errors,omitempty"` +} + +type graphQLError struct { + Message string `json:"message"` + Type string `json:"type,omitempty"` +} + +type repositoryData struct { + Repository map[string]issueNode `json:"repository"` +} + +type issueNode struct { + Number int `json:"number"` + ProjectItems projectItems `json:"projectItems"` +} + +type projectItems struct { + Nodes []projectItemNode `json:"nodes"` +} + +type projectItemNode struct { + FieldValues fieldValues `json:"fieldValues"` + ID string `json:"id"` + Project project `json:"project"` +} + +type project struct { + ID string `json:"id"` +} + +type fieldValues struct { + Nodes []fieldValueNode `json:"nodes"` +} + +type fieldValueNode struct { + Color *string `json:"color,omitempty"` + Field *fieldDetail `json:"field,omitempty"` + Name *string `json:"name,omitempty"` +} + +type fieldDetail struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Options []statusOption `json:"options,omitempty"` +} + +type statusOption struct { + Color string `json:"color"` + ID string `json:"id"` + Name string `json:"name"` +} + +type updateStatusResponse struct { + Data *updateData `json:"data"` + Errors []graphQLError `json:"errors,omitempty"` +} + +type updateData struct { + UpdateProjectV2ItemFieldValue *updateResult `json:"updateProjectV2ItemFieldValue"` +} + +type updateResult struct { + ProjectV2Item *projectV2Item `json:"projectV2Item"` +} + +type projectV2Item struct { + FieldValues fieldValues `json:"fieldValues"` +}