├── .gitattributes ├── Procfile ├── docs └── screenshot.png ├── bin └── go-pre-compile ├── codecov.yml ├── integration ├── jest.config.js └── app.test.ts ├── version.go ├── static ├── typescript │ ├── EnvContext.tsx │ ├── NavComponent.test.tsx │ ├── __snapshots__ │ │ ├── NavComponent.test.tsx.snap │ │ └── ChecklistComponent.test.tsx.snap │ ├── ChecklistComponent.test.tsx │ ├── NavComponent.tsx │ ├── api.ts │ ├── index.tsx │ ├── api-schema.ts │ ├── __mocks__ │ │ └── api.ts │ └── ChecklistComponent.tsx └── scss │ └── app.scss ├── .gitignore ├── tsconfig.json ├── docker-compose.yaml ├── scripts ├── json-schema-to-typescript └── bump-version ├── jest-puppeteer.config.js ├── lib ├── repository │ ├── bolt_test.go │ ├── redis_test.go │ ├── datastore_test.go │ ├── core.go │ ├── impl_test.go │ ├── datastore.go │ ├── bolt.go │ └── redis.go ├── gateway │ ├── github_test.go │ └── github.go ├── oauthforwarder │ └── oauthforwarder.go ├── web │ ├── web_mock_test.go │ ├── web_test.go │ └── web.go ├── mocks │ ├── github_gateway.go │ └── core_repository.go ├── usecase │ ├── github_mock_test.go │ ├── notification.go │ ├── usecase_test.go │ └── usecase.go └── repository_mock │ └── core.go ├── renovate.json ├── app.json ├── webpack.config.js ├── regconfig.json ├── .eslintrc.js ├── Dockerfile ├── context_test.go ├── go.mod ├── package.json ├── context.go ├── README.md ├── Makefile ├── cmd └── prchecklist │ └── main.go ├── .github └── workflows │ └── test.yml ├── models_test.go ├── models.go ├── jest.config.js └── CHANGELOG.md /.gitattributes: -------------------------------------------------------------------------------- 1 | /static/js/* -diff 2 | /lib/web/assets.go -diff 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: prchecklist -listen 0.0.0.0:$PORT -datasource $REDIS_URL 2 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motemen/prchecklist/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /bin/go-pre-compile: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # pre-compile script for Heroku 4 | 5 | set -e 6 | 7 | make lib/web/assets.go 8 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 5% 6 | patch: off 7 | -------------------------------------------------------------------------------- /integration/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-puppeteer', 3 | transform: { 4 | '\.tsx?$': 'ts-jest' 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package prchecklist 2 | 3 | // Version is the prchecklist release version. 4 | // Specified by Makefile. 5 | var Version = "2.7.0" 6 | -------------------------------------------------------------------------------- /static/typescript/EnvContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const EnvContext = React.createContext({ 4 | appVersion: "HEAD", 5 | }); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /prchecklist 2 | /prchecklist.db 3 | /node_modules/ 4 | /internal/ 5 | /coverage* 6 | /build/ 7 | /.bin/ 8 | *.env 9 | /static/js 10 | .reg 11 | /__screenshots__/ 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2015", 5 | "noImplicitAny": true, 6 | "noImplicitReturns": true, 7 | "noUnusedLocals": true, 8 | "strictNullChecks": false, 9 | "sourceMap": false, 10 | "jsx": "react" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /static/typescript/NavComponent.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as renderer from "react-test-renderer"; 3 | 4 | import { NavComponent } from "./NavComponent"; 5 | 6 | test("", async () => { 7 | const component = renderer.create(); 8 | 9 | const tree = component.toJSON(); 10 | expect(tree).toMatchSnapshot(); 11 | }); 12 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | build: . 5 | ports: 6 | - "${PORT-8080}:${PORT-8080}" 7 | environment: 8 | - PORT 9 | - PRCHECKLIST_BEHIND_PROXY 10 | - PRCHECKLIST_DATASOURCE 11 | - PRCHECKLIST_SESSION_SECRET 12 | - GITHUB_CLIENT_ID 13 | - GITHUB_CLIENT_SECRET 14 | - GITHUB_DOMAIN 15 | - LOCAL_CA_CERT_BASE64 16 | -------------------------------------------------------------------------------- /static/typescript/__snapshots__/NavComponent.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` 1`] = ` 4 | 22 | `; 23 | -------------------------------------------------------------------------------- /scripts/json-schema-to-typescript: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const compile = require('json-schema-to-typescript').compile; 5 | 6 | let schema = JSON.parse(fs.readFileSync(0)) 7 | schema.properties = Object.keys(schema.definitions).map(n => ({ "$ref": "#/definitions/" + n })) 8 | 9 | compile(schema, '', { bannerComment: '' }) 10 | .then(ts => process.stdout.write(ts)) 11 | .catch(err => { console.error(err); process.exit(1) }); 12 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | const debug = !!process.env["DEBUG"]; 2 | 3 | if (!process.env["PRCHECKLIST_TEST_GITHUB_TOKEN"]) { 4 | throw new Error("PRCHECKLIST_TEST_GITHUB_TOKEN must be set"); 5 | } 6 | 7 | module.exports = { 8 | launch: { 9 | headless: debug ? false : true, 10 | slowMo: debug ? 100 : 0, 11 | }, 12 | server: { 13 | command: "PRCHECKLIST_DATASOURCE=bolt:$(mktemp) yarn run serve", 14 | port: 8080, 15 | launchTimeout: 30 * 1000, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /lib/repository/bolt_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "testing" 4 | 5 | import ( 6 | "io/ioutil" 7 | "path/filepath" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestBoltRepository(t *testing.T) { 13 | require := require.New(t) 14 | 15 | tempdir, err := ioutil.TempDir("", "") 16 | require.NoError(err) 17 | 18 | repo, err := NewBoltCore("bolt:" + filepath.Join(tempdir, "test.db")) 19 | require.NoError(err) 20 | 21 | testUsers(t, repo) 22 | testChecks(t, repo) 23 | } 24 | -------------------------------------------------------------------------------- /scripts/bump-version: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | part=$1 6 | 7 | make lib/web/assets.go 8 | 9 | git diff --quiet && git diff --cached --quiet 10 | 11 | curr_version=$(gobump show -r) 12 | next_version=$(gobump "$part" -w -v -r) 13 | 14 | test -n "$curr_version" 15 | test -n "$next_version" 16 | 17 | git commit -a -m "bump version to $next_version" 18 | git tag "v$next_version" 19 | 20 | ${GHCH-ghch} -w --format=markdown --from="v$curr_version" --next-version="v$next_version" 21 | 22 | ${EDITOR-vim} CHANGELOG.md 23 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "timezone": "Asia/Tokyo", 3 | "schedule": ["every weekend"], 4 | "extends": [ 5 | "config:base" 6 | ], 7 | "packageRules": [ 8 | { 9 | "depTypeList": ["devDependencies"], 10 | "groupName": "devDependencies" 11 | }, 12 | { 13 | "groupName": "definitelyTyped", 14 | "packagePatterns": [ 15 | "^@types/" 16 | ] 17 | }, 18 | { 19 | "updateTypes": ["digest"], 20 | "enabled": false 21 | } 22 | ], 23 | "semanticCommits": true, 24 | "labels": ["renovate"], 25 | "rebaseWhen": "never" 26 | } 27 | -------------------------------------------------------------------------------- /lib/repository/redis_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | // https://cloud.google.com/datastore/docs/tools/datastore-emulator 13 | 14 | func TestRedisRepository(t *testing.T) { 15 | redisURL := os.Getenv("TEST_REDIS_URL") 16 | if !strings.HasPrefix(redisURL, "redis:") { 17 | log.Println("to test lib/repository/redis.go, set TEST_REDIS_URL") 18 | t.SkipNow() 19 | return 20 | } 21 | 22 | repo, err := NewRedisCore(redisURL) 23 | require.NoError(t, err) 24 | 25 | testUsers(t, repo) 26 | testChecks(t, repo) 27 | } 28 | -------------------------------------------------------------------------------- /static/typescript/ChecklistComponent.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ChecklistComponent } from "./ChecklistComponent"; 3 | import * as renderer from "react-test-renderer"; 4 | 5 | jest.mock("./api"); 6 | 7 | test("", async () => { 8 | const component = renderer.create( 9 | 17 | ); 18 | 19 | let tree = component.toJSON(); 20 | expect(tree).toMatchSnapshot(); 21 | 22 | await Promise.resolve(); 23 | 24 | tree = component.toJSON(); 25 | expect(tree).toMatchSnapshot(); 26 | }); 27 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prchecklist", 3 | "description": "", 4 | "scripts": {}, 5 | "env": { 6 | "GITHUB_CLIENT_ID": { 7 | "required": true 8 | }, 9 | "GITHUB_CLIENT_SECRET": { 10 | "required": true 11 | }, 12 | "PRCHECKLIST_BEHIND_PROXY": { 13 | "required": true 14 | }, 15 | "PRCHECKLIST_SESSION_SECRET": { 16 | "required": true 17 | }, 18 | "REDIS_URL": { 19 | "required": true 20 | } 21 | }, 22 | "formation": { 23 | "web": { 24 | "quantity": 1 25 | } 26 | }, 27 | "addons": ["heroku-redis"], 28 | "buildpacks": [ 29 | { 30 | "url": "heroku/nodejs" 31 | }, 32 | { 33 | "url": "heroku/go" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './static/typescript/index.tsx', 5 | output: { 6 | filename: 'bundle.js', 7 | path: path.resolve(__dirname, 'static/js') 8 | }, 9 | devtool: 'source-map', 10 | resolve: { 11 | extensions: ['.ts', '.tsx', '.js'] 12 | }, 13 | module: { 14 | rules: [ 15 | { test: /\.tsx?$/, use: [ 'ts-loader' ] }, 16 | { test: /\.scss$/, use: [ 'style-loader', 'css-loader', 'sass-loader' ] }, 17 | { test: /\.css$/, use: [ 'style-loader', 'css-loader' ] }, 18 | ] 19 | }, 20 | devServer: { 21 | port: 8080, 22 | proxy: { 23 | '/': 'http://localhost:8081' 24 | }, 25 | publicPath: '/js/' 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /lib/repository/datastore_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | // https://cloud.google.com/datastore/docs/tools/datastore-emulator 12 | 13 | func TestDatastoreRepository(t *testing.T) { 14 | if os.Getenv("DATASTORE_EMULATOR_HOST") == "" || os.Getenv("DATASTORE_PROJECT_ID") == "" { 15 | log.Println("to test lib/repository/datastore.go, set DATASTORE_EMULATOR_HOST and DATASTORE_PROJECT_ID; follow the instruction at https://cloud.google.com/datastore/docs/tools/datastore-emulator") 16 | 17 | t.SkipNow() 18 | return 19 | } 20 | 21 | repo, err := NewDatastoreCore("datastore:") 22 | require.NoError(t, err) 23 | 24 | testUsers(t, repo) 25 | testChecks(t, repo) 26 | } 27 | -------------------------------------------------------------------------------- /regconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "core": { 3 | "workingDir": ".reg", 4 | "actualDir": "__screenshots__", 5 | "thresholdRate": 0, 6 | "addIgnore": true, 7 | "ximgdiff": { 8 | "invocationType": "client" 9 | } 10 | }, 11 | "plugins": { 12 | "reg-simple-keygen-plugin": { 13 | "expectedKey": "$REG_EXPECTED_KEY", 14 | "actualKey": "$REG_ACTUAL_KEY" 15 | }, 16 | "reg-notify-github-with-api-plugin": { 17 | "githubUrl": "https://api.github.com/graphql", 18 | "prComment": true, 19 | "owner": "motemen", 20 | "repository": "prchecklist", 21 | "prCommentBehavior": "default", 22 | "privateToken": "$GITHUB_TOKEN" 23 | }, 24 | "reg-publish-s3-plugin": { 25 | "bucketName": "$REG_S3_BUCKET_NAME" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/gateway/github_test.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "golang.org/x/oauth2" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/motemen/prchecklist/v2" 13 | ) 14 | 15 | func TestGitHub_GetPullRequest(t *testing.T) { 16 | token := os.Getenv("PRCHECKLIST_TEST_GITHUB_TOKEN") 17 | if token == "" { 18 | t.Skipf("PRCHECKLIST_TEST_GITHUB_TOKEN not set") 19 | } 20 | 21 | github, err := NewGitHub() 22 | assert.NoError(t, err) 23 | 24 | ctx := context.Background() 25 | cli := oauth2.NewClient( 26 | ctx, 27 | oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), 28 | ) 29 | ctx = context.WithValue(ctx, prchecklist.ContextKeyHTTPClient, cli) 30 | _, _, err = github.GetPullRequest(ctx, prchecklist.ChecklistRef{ 31 | Owner: "motemen", 32 | Repo: "test-repository", 33 | Number: 2, 34 | }, true) 35 | assert.NoError(t, err) 36 | } 37 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "globals": { 13 | "Atomics": "readonly", 14 | "SharedArrayBuffer": "readonly" 15 | }, 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "ecmaFeatures": { 19 | "jsx": true 20 | }, 21 | "ecmaVersion": 2018, 22 | "sourceType": "module" 23 | }, 24 | "plugins": [ 25 | "react", 26 | "@typescript-eslint", 27 | "prettier" 28 | ], 29 | "rules": { 30 | "prettier/prettier": "error" 31 | }, 32 | "settings": { 33 | "react": { 34 | "version": "detect" 35 | } 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /static/typescript/NavComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as API from "./api"; 3 | import { EnvContext } from "./EnvContext"; 4 | 5 | interface NavProps { 6 | logo?: JSX.Element; 7 | stages?: JSX.Element; 8 | me?: API.GitHubUser; 9 | } 10 | 11 | export class NavComponent extends React.Component { 12 | public render() { 13 | return ( 14 | 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14 2 | 3 | WORKDIR /app 4 | 5 | 6 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] 7 | # FIXME 8 | RUN \ 9 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ 10 | echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ 11 | apt-get update && \ 12 | apt-get install --no-install-recommends -yq nodejs yarn && \ 13 | apt-get clean && \ 14 | rm -rf /var/cache/apt/archives/* /var/lib/apt/lists/* 15 | 16 | COPY go.mod go.sum ./ 17 | RUN go mod download 18 | 19 | COPY Makefile *.json *.js yarn.lock *.go ./ 20 | COPY static static 21 | COPY lib lib 22 | COPY cmd cmd 23 | COPY scripts scripts 24 | RUN make setup 25 | RUN make build BUILDFLAGS='-mod=readonly' 26 | 27 | EXPOSE 8080 28 | 29 | # For self-signed GitHub Enterprise Server 30 | CMD if [ -n "$LOCAL_CA_CERT_BASE64" ]; then echo "$LOCAL_CA_CERT_BASE64" | base64 --decode > /usr/local/share/ca-certificates/local.crt; fi && \ 31 | if [ -n "$(ls -1 /usr/local/share/ca-certificates)" ]; then update-ca-certificates; fi && \ 32 | exec ./prchecklist 33 | 34 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package prchecklist 2 | 3 | import "testing" 4 | 5 | import ( 6 | "net/http" 7 | ) 8 | 9 | func TestContextClient(t *testing.T) { 10 | } 11 | 12 | func TestRequestContext(t *testing.T) { 13 | req, err := http.NewRequest("GET", "https://example.com:1234/foo/bar", nil) 14 | if err != nil { 15 | t.Error(err) 16 | return 17 | } 18 | 19 | ctx := RequestContext(req) 20 | url := ContextRequestOrigin(ctx) 21 | if expected, got := "https://example.com:1234", url.String(); got != expected { 22 | t.Errorf("expected %v but got %v", expected, got) 23 | } 24 | } 25 | 26 | func TestBuildURL(t *testing.T) { 27 | req, err := http.NewRequest("GET", "https://example.com:1234/foo/bar", nil) 28 | if err != nil { 29 | t.Error(err) 30 | return 31 | } 32 | 33 | ctx := RequestContext(req) 34 | if expected, got := "https://example.com:1234/path/a", BuildURL(ctx, "path/a").String(); got != expected { 35 | t.Errorf("expected %v but got %v", expected, got) 36 | } 37 | 38 | if expected, got := "https://example.com:1234/path/a", BuildURL(ctx, "/path/a").String(); got != expected { 39 | t.Errorf("expected %v but got %v", expected, got) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/repository/core.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/motemen/prchecklist/v2" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type coreRepository interface { 12 | GetChecks(ctx context.Context, clRef prchecklist.ChecklistRef) (prchecklist.Checks, error) 13 | AddCheck(ctx context.Context, clRef prchecklist.ChecklistRef, key string, user prchecklist.GitHubUser) error 14 | RemoveCheck(ctx context.Context, clRef prchecklist.ChecklistRef, key string, user prchecklist.GitHubUser) error 15 | 16 | AddUser(ctx context.Context, user prchecklist.GitHubUser) error 17 | GetUsers(ctx context.Context, userIDs []int) (map[int]prchecklist.GitHubUser, error) 18 | } 19 | 20 | var registry = map[string]coreRepositoryBuilder{} 21 | 22 | type coreRepositoryBuilder func(string) (coreRepository, error) 23 | 24 | func registerCoreRepositoryBuilder(proto string, builder coreRepositoryBuilder) { 25 | registry[proto] = builder 26 | } 27 | 28 | // NewCore creates a coreRepository based on the value of datasource. 29 | func NewCore(datasource string) (coreRepository, error) { 30 | p := strings.IndexByte(datasource, ':') 31 | if p == -1 { 32 | return nil, errors.Errorf("invalid datasource: %q", datasource) 33 | } 34 | 35 | proto := datasource[:p] 36 | builder, ok := registry[proto] 37 | if !ok { 38 | return nil, errors.Errorf("cannot handle datasource: %q", datasource) 39 | } 40 | 41 | return builder(datasource) 42 | } 43 | -------------------------------------------------------------------------------- /static/typescript/api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChecklistRef, 3 | ChecklistResponse, 4 | ErrorResponse, 5 | ErrorType, 6 | MeResponse, 7 | } from "./api-schema"; 8 | 9 | export { 10 | Checklist, 11 | ChecklistRef, 12 | ChecklistResponse, 13 | ChecklistItem, 14 | ErrorResponse, 15 | GitHubUser, 16 | } from "./api-schema"; 17 | 18 | export class APIError { 19 | constructor(public errorType: ErrorType) {} 20 | } 21 | 22 | function asQueryParam(ref: ChecklistRef) { 23 | return `owner=${ref.Owner}&repo=${ref.Repo}&number=${ref.Number}&stage=${ 24 | ref.Stage || "" 25 | }`; 26 | } 27 | 28 | export function getChecklist( 29 | ref: ChecklistRef 30 | ): Promise { 31 | return fetch(`/api/checklist?${asQueryParam(ref)}`, { 32 | credentials: "same-origin", 33 | }).then((res) => { 34 | if (!res.ok) { 35 | return res.text().then((text): APIError | never => { 36 | try { 37 | // If request failed and respnose body was a JSON, it must be an ErrorResponse. 38 | const err: ErrorResponse = JSON.parse(text); 39 | return new APIError(err.Type); 40 | } catch (e) { 41 | // fallthrough 42 | } 43 | 44 | // Throw a general error. 45 | throw new Error(`${res.status} ${res.statusText}\n${text}`); 46 | }); 47 | } 48 | 49 | return res.json(); 50 | }); 51 | } 52 | 53 | export function setCheck( 54 | ref: ChecklistRef, 55 | featNum: number, 56 | checked: boolean 57 | ): Promise { 58 | return fetch(`/api/check?${asQueryParam(ref)}&featureNumber=${featNum}`, { 59 | credentials: "same-origin", 60 | method: checked ? "PUT" : "DELETE", 61 | }).then((res) => { 62 | if (!res.ok) { 63 | return res.text().then((text) => { 64 | throw new Error(`${res.status} ${res.statusText}\n${text}`); 65 | }); 66 | } 67 | return res.json(); 68 | }); 69 | } 70 | 71 | export function getMe(): Promise { 72 | return fetch("/api/me", { 73 | credentials: "same-origin", 74 | }).then((res) => res.json()); 75 | } 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/motemen/prchecklist/v2 2 | 3 | go 1.12 4 | 5 | require ( 6 | cloud.google.com/go v0.57.0 7 | cloud.google.com/go/datastore v1.1.0 8 | github.com/Songmu/gocredits v0.1.0 // indirect 9 | github.com/a-urth/go-bindata v0.0.0-20180209162145-df38da164efc // indirect 10 | github.com/boltdb/bolt v1.3.1 11 | github.com/cespare/reflex v0.2.0 // indirect 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/elazarl/go-bindata-assetfs v1.0.1 14 | github.com/fsnotify/fsnotify v1.4.9 // indirect 15 | github.com/garyburd/redigo v1.6.0 16 | github.com/golang/mock v1.6.0 17 | github.com/google/go-github/v31 v31.0.0 18 | github.com/google/go-querystring v1.0.0 // indirect 19 | github.com/gorilla/handlers v1.5.1 20 | github.com/gorilla/mux v1.8.0 21 | github.com/gorilla/schema v1.2.0 22 | github.com/gorilla/sessions v1.2.0 23 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 24 | github.com/kr/pretty v0.1.0 // indirect 25 | github.com/lestrrat-go/jspointer v0.0.0-20181205001929-82fadba7561c // indirect 26 | github.com/lestrrat-go/jsref v0.0.0-20181205001954-1b590508f37d // indirect 27 | github.com/lestrrat-go/pdebug v0.0.0-20200204225717-4d6bd78da58d // indirect 28 | github.com/lestrrat-go/structinfo v0.0.0-20190212233437-acd51874663b // indirect 29 | github.com/lestrrat/go-jsschema v0.0.0-20181205002244-5c81c58ffcc3 // indirect 30 | github.com/motemen/go-generate-jsschema v0.0.0-20170921015939-f9efddabe75d // indirect 31 | github.com/motemen/go-graphql-query v0.0.0-20190808105856-1e064957a3ee 32 | github.com/motemen/go-loghttp v0.0.0-20170804080138-974ac5ceac27 33 | github.com/motemen/go-nuts v0.0.0-20190725124253-1d2432db96b0 34 | github.com/ogier/pflag v0.0.1 // indirect 35 | github.com/patrickmn/go-cache v2.1.0+incompatible 36 | github.com/pkg/errors v0.9.1 37 | github.com/stretchr/testify v1.7.1 38 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d 39 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c 40 | golang.org/x/tools v0.1.5 41 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 42 | gopkg.in/yaml.v2 v2.3.0 43 | ) 44 | -------------------------------------------------------------------------------- /lib/oauthforwarder/oauthforwarder.go: -------------------------------------------------------------------------------- 1 | package oauthforwarder 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | ) 11 | 12 | // Forwarder is the implementation root regarding OAuth callback forwarder. 13 | type Forwarder struct { 14 | CallbackURL *url.URL 15 | Secret []byte 16 | } 17 | 18 | func (f *Forwarder) hashString(in string) []byte { 19 | h := hmac.New(sha256.New, f.Secret) 20 | h.Write([]byte(in)) 21 | return h.Sum(nil) 22 | } 23 | 24 | // CreateURL creates URL to callback host which in success returns back to callback. 25 | func (f *Forwarder) CreateURL(callback string) *url.URL { 26 | u := *f.CallbackURL 27 | q := u.Query() 28 | q.Set("to", callback) 29 | q.Set("sig", fmt.Sprintf("%x", f.hashString(callback))) 30 | u.RawQuery = q.Encode() 31 | return &u 32 | } 33 | 34 | // Wrap wraps an http.Handler which forwards client to original callback URL. 35 | func (f *Forwarder) Wrap(base http.Handler) http.Handler { 36 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 37 | if req.URL.Path != f.CallbackURL.Path { 38 | base.ServeHTTP(w, req) 39 | return 40 | } 41 | 42 | query := req.URL.Query() 43 | 44 | to := query.Get("to") 45 | sig := query.Get("sig") 46 | 47 | if to == "" || sig == "" { 48 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 49 | return 50 | } 51 | 52 | forwardURL, err := url.Parse(to) 53 | if err != nil { 54 | http.Error(w, "Invalid URL", http.StatusBadRequest) 55 | return 56 | } 57 | 58 | sigBytes := make([]byte, 32) 59 | _, err = hex.Decode(sigBytes, []byte(sig)) 60 | if err != nil { 61 | http.Error(w, "Invalid signature", http.StatusBadRequest) 62 | return 63 | } 64 | if !hmac.Equal(f.hashString(to), sigBytes) { 65 | http.Error(w, "Invalid signature", http.StatusBadRequest) 66 | return 67 | } 68 | 69 | q := forwardURL.Query() 70 | q.Set("code", query.Get("code")) 71 | q.Set("state", query.Get("state")) 72 | forwardURL.RawQuery = q.Encode() 73 | 74 | http.Redirect(w, req, forwardURL.String(), http.StatusTemporaryRedirect) 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prchecklist", 3 | "version": "0.0.0", 4 | "repository": "git@github.com:motemen/prchecklist.git", 5 | "author": "motemen ", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "@types/babel-core": "6.25.6", 9 | "@types/eslint": "6.8.1", 10 | "@types/eslint-plugin-prettier": "3.1.0", 11 | "@types/expect-puppeteer": "4.4.5", 12 | "@types/jest": "25.2.3", 13 | "@types/jest-environment-puppeteer": "4.4.1", 14 | "@types/node-sass": "4.11.1", 15 | "@types/prettier": "2.3.1", 16 | "@types/puppeteer": "2.1.5", 17 | "@types/react": "16.14.10", 18 | "@types/react-dom": "16.9.13", 19 | "@types/react-test-renderer": "16.9.5", 20 | "@types/uikit": "3.3.1", 21 | "@types/webpack": "4.41.29", 22 | "@types/webpack-dev-server": "3.11.4", 23 | "@typescript-eslint/eslint-plugin": "2.34.0", 24 | "@typescript-eslint/parser": "2.34.0", 25 | "babel-core": "6.26.3", 26 | "babel-preset-es2015": "6.24.1", 27 | "css-loader": "3.5.3", 28 | "dtsgenerator": "2.7.0", 29 | "eslint": "6.8.0", 30 | "eslint-plugin-prettier": "3.1.3", 31 | "eslint-plugin-react": "7.20.0", 32 | "jest": "25.5.4", 33 | "jest-puppeteer": "4.4.0", 34 | "json-schema-to-typescript": "9.1.0", 35 | "node-sass": "4.14.1", 36 | "npm-run-all": "4.1.5", 37 | "prettier": "2.0.5", 38 | "puppeteer": "3.3.0", 39 | "react-test-renderer": "16.13.1", 40 | "reg-notify-github-with-api-plugin": "^0.10.16", 41 | "reg-publish-s3-plugin": "^0.10.16", 42 | "reg-simple-keygen-plugin": "^0.10.16", 43 | "reg-suit": "^0.10.16", 44 | "sass-loader": "8.0.2", 45 | "style-loader": "1.2.1", 46 | "ts-jest": "25.5.1", 47 | "ts-loader": "7.0.5", 48 | "typescript": "3.9.5", 49 | "webpack": "4.43.0", 50 | "webpack-cli": "3.3.11", 51 | "webpack-dev-server": "3.11.0" 52 | }, 53 | "dependencies": { 54 | "react": "17.0.2", 55 | "react-dom": "17.0.2", 56 | "uikit": "3.13.10" 57 | }, 58 | "scripts": { 59 | "test": "jest", 60 | "serve": "npm-run-all -p serve:*", 61 | "serve:frontend": "webpack-dev-server", 62 | "serve:backend": "go run ./cmd/prchecklist --listen localhost:8081" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package prchecklist 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | ) 8 | 9 | type contextKey struct{ name string } 10 | 11 | // ContextKeyHTTPClient is the context key to use with context.WithValue 12 | // to associate an *http.Client to a context. 13 | // The associated client can be retrieved by ContextClient. 14 | // In this application, the client should be one created by oauth2.NewClient. 15 | // TODO(motemen): use go4.org/ctxutil? 16 | var ContextKeyHTTPClient = &contextKey{"httpClient"} 17 | 18 | // ContextClient returns a client associated with the context ctx. 19 | // If none is found, returns http.DefaultClient. 20 | func ContextClient(ctx context.Context) *http.Client { 21 | if client, ok := ctx.Value(ContextKeyHTTPClient).(*http.Client); ok && client != nil { 22 | return client 23 | } 24 | 25 | return http.DefaultClient 26 | } 27 | 28 | var contextKeyRequestOrigin = &contextKey{"requestOrigin"} 29 | 30 | // RequestContext creates a context from an HTTP request req, 31 | // along with the origin data constructed from client request. 32 | func RequestContext(req *http.Request) context.Context { 33 | ctx := req.Context() 34 | origin := &url.URL{ 35 | Scheme: req.URL.Scheme, 36 | Host: req.Host, 37 | } 38 | if origin.Scheme == "" { 39 | origin.Scheme = "http" 40 | } 41 | return context.WithValue(ctx, contextKeyRequestOrigin, origin) 42 | } 43 | 44 | // ContextRequestOrigin retrieves origin data from the context 45 | // created by RequestContext. 46 | func ContextRequestOrigin(ctx context.Context) *url.URL { 47 | return ctx.Value(contextKeyRequestOrigin).(*url.URL) 48 | } 49 | 50 | // BuildURL builds an absolute URL with a given path. 51 | // Context ctx must be one obtained by RequestContext. 52 | func BuildURL(ctx context.Context, path string) *url.URL { 53 | origin := ContextRequestOrigin(ctx) 54 | return &url.URL{ 55 | Scheme: origin.Scheme, 56 | Host: origin.Host, 57 | Path: path, 58 | } 59 | } 60 | 61 | // NewContextWithValuesOf creates new context which inherits values of base context. 62 | func NewContextWithValuesOf(base context.Context) context.Context { 63 | ctx := context.Background() 64 | ctx = context.WithValue(ctx, ContextKeyHTTPClient, base.Value(ContextKeyHTTPClient)) 65 | ctx = context.WithValue(ctx, contextKeyRequestOrigin, base.Value(contextKeyRequestOrigin)) 66 | return ctx 67 | } 68 | -------------------------------------------------------------------------------- /integration/app.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | jest.setTimeout(30 * 1000); 4 | 5 | describe("prchecklist", () => { 6 | const targetPath = "motemen/test-repository/pull/2"; 7 | const screenshotPath = process.env["TEST_SCREENSHOT_PATH"]; 8 | 9 | const maySaveScreenshot = async (filename: string): Promise => { 10 | if (screenshotPath) { 11 | await page.screenshot({ 12 | path: path.join(screenshotPath, filename), 13 | fullPage: true, 14 | }); 15 | } 16 | }; 17 | 18 | beforeEach(async () => { 19 | await page.goto(`http://localhost:8080/debug/auth-for-testing`); 20 | await page.goto(`http://localhost:8080/${targetPath}`); 21 | await page.waitForNavigation({ waitUntil: "networkidle2" }); 22 | }); 23 | 24 | it("checks PRs", async () => { 25 | await page.waitForSelector("#checklist-items"); 26 | 27 | const href = await page.$eval( 28 | ".title a", 29 | (el: HTMLAnchorElement) => el.href 30 | ); 31 | expect(href).toEqual(`https://github.com/${targetPath}`); 32 | 33 | await maySaveScreenshot("01-pr-view-unchecked.png"); 34 | 35 | await page.click("#checklist-items ul li:nth-child(1) button"); 36 | 37 | await page.waitFor(1000); 38 | 39 | const checked = await page.$eval( 40 | "#checklist-items ul li:nth-child(1) button", 41 | (el: HTMLElement) => el.classList.contains("checked") 42 | ); 43 | expect(checked).toBeTruthy(); 44 | 45 | await maySaveScreenshot("02-pr-view-checked-1.png"); 46 | 47 | const elems = await page.$$("#checklist-items ul li + li button"); 48 | for (const el of elems) { 49 | await el.click(); 50 | } 51 | 52 | await page.waitFor(1000); 53 | await maySaveScreenshot("03-pr-view-checked-all.png"); 54 | }); 55 | 56 | it("changes stages", async () => { 57 | const stagesElem = await page.waitForSelector("select.stages"); 58 | 59 | await expect( 60 | page.$eval("select.stages", (el) => (el as HTMLSelectElement).value) 61 | ).resolves.toEqual("qa"); 62 | 63 | await maySaveScreenshot("11-pr-view-stage-before-change.png"); 64 | 65 | await stagesElem.select("production"); 66 | await page.waitForNavigation({ waitUntil: "networkidle2" }); 67 | 68 | await expect( 69 | page.$eval("select.stages", (el) => (el as HTMLSelectElement).value) 70 | ).resolves.toEqual("production"); 71 | 72 | await maySaveScreenshot("12-pr-view-stage-changed.png"); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /static/typescript/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | 4 | import * as API from "./api"; 5 | import { ChecklistComponent } from "./ChecklistComponent"; 6 | import { NavComponent } from "./NavComponent"; 7 | import { EnvContext } from "./EnvContext"; 8 | 9 | import "../scss/app.scss"; 10 | 11 | const appVersion = document 12 | .querySelector('meta[name="prchecklist-version"]') 13 | ?.getAttribute("content"); 14 | 15 | if (/^\/([^/]+)\/([^/]+)\/pull\/(\d+)$/.test(location.pathname)) { 16 | ReactDOM.render( 17 | 18 | 26 | , 27 | document.querySelector("#main") 28 | ); 29 | } else if ( 30 | /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/([^/]+)$/.test(location.pathname) 31 | ) { 32 | ReactDOM.render( 33 | 34 | 42 | , 43 | document.querySelector("#main") 44 | ); 45 | } else { 46 | API.getMe().then((data) => { 47 | ReactDOM.render( 48 | 49 |
50 | 51 | {data.PullRequests ? ( 52 |
53 | {Object.keys(data.PullRequests).map((repoPath: string) => ( 54 |
55 |

{repoPath}

56 | 65 |
66 | ))} 67 |
68 | ) : ( 69 | [] 70 | )} 71 |
72 |
, 73 | document.querySelector("#main") 74 | ); 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prchecklist [![Build Status](https://travis-ci.org/motemen/prchecklist.svg?branch=master)](https://travis-ci.org/motemen/prchecklist) [![Gitter](https://img.shields.io/gitter/room/motemen/prchecklist.svg)](https://gitter.im/motemen/prchecklist?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 2 | 3 | Checklist for "release" pull requests, which includes feature pull requests to merge into mainline 4 | 5 | [Demo](https://prchecklist.herokuapp.com/motemen/test-repository/pull/2) 6 | 7 | ![Screenshot](docs/screenshot.png) 8 | 9 | ## Features 10 | 11 | - Provides checklists base on "release" pull requests which have multiple "feature" pull requests that are about to merged into master by the "release" one (see the demo) 12 | - Each checklist item can be checked by GitHub accounts who have access to the repository 13 | - Checklists can have multiple stages (eg. QA and production) 14 | - Notifies to Slack on each check or checklist completion 15 | - Customizable by a .yml file on repositories 16 | 17 | ## Configuration 18 | 19 | By a file named `prchecklist.yml` on the top of the repository you can customize prchecklist's behavior. 20 | 21 | For example it seems like: 22 | 23 | ~~~yaml 24 | stages: 25 | - qa 26 | - production 27 | notification: 28 | events: 29 | on_check: 30 | - ch_check 31 | on_complete: 32 | - ch_complete 33 | channels: 34 | ch_complete: 35 | url: https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX 36 | ch_check: 37 | url: https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX 38 | ~~~ 39 | 40 | This configuration says: 41 | 42 | - This repository has two stages (qa, production), which means two checklists are created for each release pull requests, 43 | - And when a checklist item is checked, a Slack notification is sent, 44 | - And when a checklist is completed, a Slack notification is sent to another Slack channel. 45 | 46 | ## Development 47 | 48 | Requires [Go][] and [yarn][]. 49 | 50 | Register an OAuth application [on GitHub](https://github.com/settings/applications/new), with callback URL configured as `http://localhost:8080/auth/callback`. Set OAuth client ID/secret as `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` environment variables respectively. 51 | 52 | $ make develop 53 | $ open http://localhost:8080/ 54 | 55 | ## Building 56 | 57 | $ make # builds "prchecklist" stand-alone binary 58 | 59 | [Go]: https://golang.org/ 60 | [yarn]: https://yarnpkg.com/ 61 | 62 | ## Author 63 | 64 | motemen, with great support of [Hatena](http://hatenacorp.jp/) folks. 65 | -------------------------------------------------------------------------------- /lib/repository/impl_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import "testing" 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/motemen/prchecklist/v2" 12 | ) 13 | 14 | func testUsers(t *testing.T, repo coreRepository) { 15 | t.Helper() 16 | 17 | t.Run("Users", func(t *testing.T) { 18 | assert := assert.New(t) 19 | require := require.New(t) 20 | 21 | ctx := context.Background() 22 | 23 | u1 := prchecklist.GitHubUser{ 24 | ID: 1, 25 | Login: "user1", 26 | } 27 | u2 := prchecklist.GitHubUser{ 28 | ID: 2, 29 | Login: "user2", 30 | } 31 | 32 | _, err := repo.GetUsers(ctx, []int{1, 2}) 33 | require.Error(err, "should be an error: GetUsers for nonexistent users") 34 | 35 | require.NoError(repo.AddUser(ctx, u1)) 36 | require.NoError(repo.AddUser(ctx, u2)) 37 | 38 | users, err := repo.GetUsers(ctx, []int{1, 2}) 39 | require.NoError(err) 40 | 41 | assert.Equal(1, users[1].ID) 42 | assert.Equal(2, users[2].ID) 43 | assert.Equal("user1", users[1].Login) 44 | }) 45 | } 46 | 47 | func testChecks(t *testing.T, repo coreRepository) { 48 | t.Helper() 49 | 50 | t.Run("Checks", func(t *testing.T) { 51 | assert := assert.New(t) 52 | require := require.New(t) 53 | 54 | ctx := context.Background() 55 | 56 | clRef := prchecklist.ChecklistRef{ 57 | Owner: "test", 58 | Repo: "repo", 59 | Number: 1, 60 | Stage: "default", 61 | } 62 | 63 | checks, err := repo.GetChecks(ctx, clRef) 64 | require.NoError(err) 65 | assert.Equal(0, len(checks)) 66 | 67 | u1 := prchecklist.GitHubUser{ 68 | ID: 1, 69 | Login: "user1", 70 | } 71 | u2 := prchecklist.GitHubUser{ 72 | ID: 2, 73 | Login: "user2", 74 | } 75 | 76 | require.NoError(repo.AddCheck(ctx, clRef, "100", u1)) 77 | 78 | checks, err = repo.GetChecks(ctx, clRef) 79 | require.NoError(err) 80 | 81 | assert.Equal(1, len(checks)) 82 | assert.Equal([]int{u1.ID}, checks["100"]) 83 | 84 | require.NoError(repo.AddCheck(ctx, clRef, "101", u1)) 85 | require.NoError(repo.AddCheck(ctx, clRef, "101", u2)) 86 | 87 | checks, err = repo.GetChecks(ctx, clRef) 88 | require.NoError(err) 89 | 90 | assert.Equal(2, len(checks)) 91 | assert.Equal([]int{u1.ID, u2.ID}, checks["101"]) 92 | 93 | require.NoError(repo.RemoveCheck(ctx, clRef, "101", u1)) 94 | 95 | checks, err = repo.GetChecks(ctx, clRef) 96 | require.NoError(err) 97 | 98 | assert.Equal(2, len(checks)) 99 | assert.Equal([]int{u2.ID}, checks["101"]) 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOBINDATA = go run github.com/a-urth/go-bindata/go-bindata 2 | MOCKGEN = go run github.com/golang/mock/mockgen 3 | REFLEX = go run github.com/cespare/reflex 4 | GOCREDITS = go run github.com/Songmu/gocredits/cmd/gocredits 5 | GOJSSCHEMAGEN = go run github.com/motemen/go-generate-jsschema/cmd/gojsschemagen 6 | GOLINT = go run golang.org/x/lint/golint 7 | 8 | WEBPACK = yarn webpack 9 | WEBPACKDEVSERVER = yarn webpack-dev-server 10 | ESLINT = yarn eslint 11 | 12 | VERSION := $(shell git describe --tags HEAD 2> /dev/null) 13 | 14 | ifeq ($(VERSION),) 15 | GOLDFLAGS = 16 | else 17 | GOLDFLAGS = -X github.com/motemen/prchecklist/v2.Version=$(VERSION) 18 | endif 19 | GOOSARCH = linux/amd64 20 | 21 | bundled_sources = $(wildcard static/typescript/* static/scss/*) 22 | export GO111MODULE=on 23 | 24 | .PHONY: default 25 | default: build 26 | 27 | .PHONY: setup 28 | setup: setup-node 29 | 30 | .PHONY: setup-node 31 | setup-node: 32 | yarn install --frozen-lockfile 33 | 34 | node_modules/%: package.json 35 | @$(MAKE) setup-node 36 | @touch $@ 37 | 38 | .PHONY: build 39 | build: lib/web/assets.go 40 | go build \ 41 | $(BUILDFLAGS) \ 42 | -ldflags "$(GOLDFLAGS)" \ 43 | -v \ 44 | ./cmd/prchecklist 45 | 46 | .PHONY: lint 47 | lint: lint-go lint-ts 48 | 49 | lint-go: 50 | $(GOLINT) -min_confidence=0.9 -set_exit_status . ./lib/... 51 | go vet . ./lib/... 52 | 53 | lint-ts: 54 | $(ESLINT) 'static/typescript/**/*.{ts,tsx}' 55 | 56 | fix: 57 | $(ESLINT) 'static/typescript/**/*.{ts,tsx}' --fix --quiet 58 | 59 | lib/mocks: 60 | go generate -x ./lib/... 61 | 62 | .PHONY: test 63 | test: test-unit test-integration 64 | 65 | .PHONY: test-unit 66 | test-unit: test-go test-ts 67 | 68 | .PHONY: test-go 69 | test-go: lib/mocks 70 | go test -v -coverprofile=coverage.out . ./lib/... 71 | 72 | .PHONY: test-ts 73 | test-ts: 74 | yarn test --coverage --coverageDirectory=./coverage 75 | 76 | .PHONY: test-integration 77 | test-integration: 78 | ifdef PRCHECKLIST_TEST_GITHUB_TOKEN 79 | yarn jest -c ./integration/jest.config.js 80 | else 81 | $(warning PRCHECKLIST_TEST_GITHUB_TOKEN is not set) 82 | endif 83 | 84 | .PHONY: develop 85 | develop: 86 | test "$$GITHUB_CLIENT_ID" && test "$$GITHUB_CLIENT_SECRET" 87 | $(WEBPACKDEVSERVER) & \ 88 | { $(REFLEX) -r '\.go\z' -R node_modules -s -- \ 89 | sh -c 'make build && ./prchecklist --listen localhost:8081'; } 90 | 91 | lib/web/assets.go: static/js/bundle.js static/text/licenses 92 | $(GOBINDATA) -pkg web -o $@ -prefix static/ -modtime 1 static/js static/text 93 | 94 | static/js/bundle.js: static/typescript/api-schema.ts $(bundled_sources) 95 | $(WEBPACK) --progress 96 | 97 | static/text/licenses: 98 | $(GOCREDITS) . > $@ 99 | 100 | static/typescript/api-schema.ts: models.go node_modules/json-schema-to-typescript 101 | $(GOJSSCHEMAGEN) $< | ./scripts/json-schema-to-typescript > $@ 102 | -------------------------------------------------------------------------------- /cmd/prchecklist/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | "time" 13 | 14 | _ "github.com/motemen/go-loghttp/global" 15 | 16 | "github.com/motemen/prchecklist/v2" 17 | "github.com/motemen/prchecklist/v2/lib/gateway" 18 | "github.com/motemen/prchecklist/v2/lib/repository" 19 | "github.com/motemen/prchecklist/v2/lib/usecase" 20 | "github.com/motemen/prchecklist/v2/lib/web" 21 | ) 22 | 23 | var ( 24 | datasource string 25 | addr string 26 | showVersion bool 27 | showLicenses bool 28 | ) 29 | 30 | const shutdownTimeout = 30 * time.Second 31 | 32 | func getenv(key, def string) string { 33 | v := os.Getenv(key) 34 | if v != "" { 35 | return v 36 | } 37 | 38 | return def 39 | } 40 | 41 | func init() { 42 | var defaultDatasource = "bolt:./prchecklist.db" 43 | if os.Getenv("GOOGLE_CLOUD_PROJECT") != "" { 44 | defaultDatasource = "datastore:" + os.Getenv("GOOGLE_CLOUD_PROJECT") 45 | } 46 | 47 | datasource = getenv("PRCHECKLIST_DATASOURCE", defaultDatasource) 48 | flag.StringVar(&datasource, "datasource", datasource, "database source name (PRCHECKLIST_DATASOURCE)") 49 | port := os.Getenv("PORT") 50 | if port == "" { 51 | port = "8080" 52 | } 53 | flag.StringVar(&addr, "listen", ":"+port, "`address` to listen") 54 | flag.BoolVar(&showVersion, "version", false, "show version information") 55 | flag.BoolVar(&showLicenses, "licenses", false, "show license notifications") 56 | } 57 | 58 | func main() { 59 | log.SetFlags(log.LstdFlags | log.Lshortfile) 60 | 61 | flag.Parse() 62 | 63 | if showVersion { 64 | fmt.Printf("prchecklist %s\n", prchecklist.Version) 65 | os.Exit(0) 66 | } 67 | 68 | if showLicenses { 69 | b := web.MustAsset("text/licenses") 70 | fmt.Println(string(b)) 71 | os.Exit(0) 72 | } 73 | 74 | coreRepo, err := repository.NewCore(datasource) 75 | if err != nil { 76 | log.Fatal(err) 77 | } 78 | 79 | github, err := gateway.NewGitHub() 80 | if err != nil { 81 | log.Fatal(err) 82 | } 83 | 84 | app := usecase.New(github, coreRepo) 85 | w := web.New(app, github) 86 | 87 | log.Printf("prchecklist starting at %s", addr) 88 | 89 | server := http.Server{ 90 | Addr: addr, 91 | Handler: w.Handler(), 92 | } 93 | 94 | go func() { 95 | err := server.ListenAndServe() 96 | log.Println(err) 97 | }() 98 | 99 | sigc := make(chan os.Signal, 1) 100 | signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP) 101 | 102 | sig := <-sigc 103 | log.Printf("received signal %q; shutdown gracefully in %s ...", sig, shutdownTimeout) 104 | 105 | ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) 106 | defer cancel() 107 | 108 | errc := make(chan error) 109 | go func() { errc <- server.Shutdown(ctx) }() 110 | 111 | select { 112 | case sig := <-sigc: 113 | log.Printf("received 2nd signal %q; shutdown now", sig) 114 | cancel() 115 | server.Close() 116 | 117 | case err := <-errc: 118 | if err != nil { 119 | log.Fatalf("while shutdown: %s", err) 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | 11 | test: 12 | runs-on: ubuntu-latest 13 | services: 14 | redis: 15 | image: redis:alpine 16 | ports: 17 | - '6379:6379' 18 | datastore-emulator: 19 | image: motemen/datastore-emulator:alpine 20 | ports: 21 | - '18081:8081' 22 | env: 23 | CLOUDSDK_CORE_PROJECT: prchecklist-test 24 | 25 | env: 26 | TEST_REDIS_URL: 'redis://localhost:6379' 27 | DATASTORE_EMULATOR_HOST: 'localhost:18081' 28 | DATASTORE_PROJECT_ID: prchecklist-test 29 | 30 | steps: 31 | 32 | - uses: actions/setup-go@v2 33 | with: 34 | go-version: 1.14 35 | 36 | - uses: actions/setup-node@v2 37 | with: 38 | node-version: 14 39 | 40 | - uses: actions/checkout@v2 41 | with: 42 | fetch-depth: 0 43 | 44 | - id: yarn-cache-dir-path 45 | run: echo "::set-output name=dir::$(yarn cache dir)" 46 | 47 | - name: Cache Yarn 48 | uses: actions/cache@v2 49 | with: 50 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 51 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 52 | restore-keys: | 53 | ${{ runner.os }}-yarn- 54 | 55 | - name: Cache Go 56 | uses: actions/cache@v2 57 | with: 58 | path: ~/go/pkg/mod 59 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 60 | restore-keys: | 61 | ${{ runner.os }}-go- 62 | 63 | - uses: aws-actions/configure-aws-credentials@v1 64 | with: 65 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 66 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 67 | aws-region: ap-northeast-1 68 | 69 | - run: | 70 | make setup 71 | 72 | - run: | 73 | make lint 74 | 75 | - run: | 76 | make build 77 | 78 | - name: Test (unit) 79 | run: | 80 | make test-unit 81 | env: 82 | PRCHECKLIST_TEST_GITHUB_TOKEN: ${{ secrets.PRCHECKLIST_TEST_GITHUB_TOKEN }} 83 | 84 | - name: Test (integration) 85 | run: | 86 | mkdir __screenshots__ 87 | TEST_SCREENSHOT_PATH=__screenshots__ make test-integration 88 | env: 89 | PRCHECKLIST_TEST_GITHUB_TOKEN: ${{ secrets.PRCHECKLIST_TEST_GITHUB_TOKEN }} 90 | 91 | - name: Run reg-suit 92 | run: | 93 | # workaround for reg-notify-github-with-api-plugin 94 | test -n "$GITHUB_HEAD_REF" && git checkout -B "${GITHUB_HEAD_REF#refs/heads}" 95 | 96 | git fetch -v origin HEAD 97 | merge_base=$(git merge-base HEAD FETCH_HEAD) 98 | if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then 99 | merge_base=$(git rev-parse HEAD~1) 100 | fi 101 | 102 | export REG_EXPECTED_KEY=$merge_base 103 | export REG_ACTUAL_KEY=$(git rev-parse HEAD) 104 | 105 | yarn reg-suit run 106 | env: 107 | REG_S3_BUCKET_NAME: motemen-reg-suit 108 | GITHUB_TOKEN: ${{ github.token }} 109 | 110 | - uses: codecov/codecov-action@v2.0.3 111 | -------------------------------------------------------------------------------- /lib/web/web_mock_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/motemen/prchecklist/v2/lib/web (interfaces: GitHubGateway) 3 | 4 | // Package web is a generated GoMock package. 5 | package web 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | prchecklist "github.com/motemen/prchecklist/v2" 11 | oauth2 "golang.org/x/oauth2" 12 | url "net/url" 13 | reflect "reflect" 14 | ) 15 | 16 | // MockGitHubGateway is a mock of GitHubGateway interface 17 | type MockGitHubGateway struct { 18 | ctrl *gomock.Controller 19 | recorder *MockGitHubGatewayMockRecorder 20 | } 21 | 22 | // MockGitHubGatewayMockRecorder is the mock recorder for MockGitHubGateway 23 | type MockGitHubGatewayMockRecorder struct { 24 | mock *MockGitHubGateway 25 | } 26 | 27 | // NewMockGitHubGateway creates a new mock instance 28 | func NewMockGitHubGateway(ctrl *gomock.Controller) *MockGitHubGateway { 29 | mock := &MockGitHubGateway{ctrl: ctrl} 30 | mock.recorder = &MockGitHubGatewayMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use 35 | func (m *MockGitHubGateway) EXPECT() *MockGitHubGatewayMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // AuthCodeURL mocks base method 40 | func (m *MockGitHubGateway) AuthCodeURL(arg0 string, arg1 *url.URL) string { 41 | m.ctrl.T.Helper() 42 | ret := m.ctrl.Call(m, "AuthCodeURL", arg0, arg1) 43 | ret0, _ := ret[0].(string) 44 | return ret0 45 | } 46 | 47 | // AuthCodeURL indicates an expected call of AuthCodeURL 48 | func (mr *MockGitHubGatewayMockRecorder) AuthCodeURL(arg0, arg1 interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthCodeURL", reflect.TypeOf((*MockGitHubGateway)(nil).AuthCodeURL), arg0, arg1) 51 | } 52 | 53 | // AuthenticateUser mocks base method 54 | func (m *MockGitHubGateway) AuthenticateUser(arg0 context.Context, arg1 string) (*prchecklist.GitHubUser, error) { 55 | m.ctrl.T.Helper() 56 | ret := m.ctrl.Call(m, "AuthenticateUser", arg0, arg1) 57 | ret0, _ := ret[0].(*prchecklist.GitHubUser) 58 | ret1, _ := ret[1].(error) 59 | return ret0, ret1 60 | } 61 | 62 | // AuthenticateUser indicates an expected call of AuthenticateUser 63 | func (mr *MockGitHubGatewayMockRecorder) AuthenticateUser(arg0, arg1 interface{}) *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthenticateUser", reflect.TypeOf((*MockGitHubGateway)(nil).AuthenticateUser), arg0, arg1) 66 | } 67 | 68 | // GetUserFromToken mocks base method 69 | func (m *MockGitHubGateway) GetUserFromToken(arg0 context.Context, arg1 *oauth2.Token) (*prchecklist.GitHubUser, error) { 70 | m.ctrl.T.Helper() 71 | ret := m.ctrl.Call(m, "GetUserFromToken", arg0, arg1) 72 | ret0, _ := ret[0].(*prchecklist.GitHubUser) 73 | ret1, _ := ret[1].(error) 74 | return ret0, ret1 75 | } 76 | 77 | // GetUserFromToken indicates an expected call of GetUserFromToken 78 | func (mr *MockGitHubGatewayMockRecorder) GetUserFromToken(arg0, arg1 interface{}) *gomock.Call { 79 | mr.mock.ctrl.T.Helper() 80 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserFromToken", reflect.TypeOf((*MockGitHubGateway)(nil).GetUserFromToken), arg0, arg1) 81 | } 82 | -------------------------------------------------------------------------------- /static/typescript/api-schema.ts: -------------------------------------------------------------------------------- 1 | export type ErrorType = "not_authed"; 2 | 3 | /** 4 | * Checklist is the main entity of prchecklist. 5 | * It is identified by a "release" pull request PullRequest 6 | * (which is identified by its Owner, Repo and Number) and a Stage, if any. 7 | * The checklist Items correspond to "feature" pull requests 8 | * that have been merged into the head of "release" pull request 9 | * and the "release" pull request is about to merge into master. 10 | */ 11 | export interface Checklist { 12 | Body: string; 13 | /** 14 | * Filled for "base" pull reqs 15 | */ 16 | Commits: Commit[]; 17 | Config: ChecklistConfig; 18 | ConfigBlobID: string; 19 | IsPrivate: boolean; 20 | Items: ChecklistItem[]; 21 | Number: number; 22 | Owner: string; 23 | Repo: string; 24 | Stage: string; 25 | Title: string; 26 | URL: string; 27 | User: GitHubUserSimple; 28 | } 29 | /** 30 | * Commit is a commit data on GitHub. 31 | */ 32 | export interface Commit { 33 | Message: string; 34 | Oid: string; 35 | } 36 | /** 37 | * ChecklistConfig is a configuration object for the repository, 38 | * which is specified by prchecklist.yml on the top of the repository. 39 | */ 40 | export interface ChecklistConfig { 41 | Notification: { 42 | Channels: { 43 | [k: string]: { 44 | URL: string; 45 | }; 46 | }; 47 | Events: { 48 | OnCheck: string[]; 49 | OnComplete: string[]; 50 | OnCompleteChecksOfUser: string[]; 51 | OnRemove: string[]; 52 | }; 53 | }; 54 | Stages: string[]; 55 | } 56 | /** 57 | * ChecklistItem is a checklist item, which belongs to a Checklist 58 | * and can be checked by multiple GitHubUsers. 59 | */ 60 | export interface ChecklistItem { 61 | Body: string; 62 | CheckedBy: GitHubUser[]; 63 | /** 64 | * Filled for "base" pull reqs 65 | */ 66 | Commits: Commit[]; 67 | ConfigBlobID: string; 68 | IsPrivate: boolean; 69 | Number: number; 70 | Owner: string; 71 | Repo: string; 72 | Title: string; 73 | URL: string; 74 | User: GitHubUserSimple; 75 | } 76 | /** 77 | * GitHubUser is represents a GitHub user. 78 | * Its Token field is populated only for the representation of 79 | * a visiting client. 80 | */ 81 | export interface GitHubUser { 82 | AvatarURL: string; 83 | ID: number; 84 | Login: string; 85 | } 86 | /** 87 | * GitHubUserSimple is a minimalistic GitHub user data. 88 | */ 89 | export interface GitHubUserSimple { 90 | Login: string; 91 | } 92 | /** 93 | * ChecklistRef represents a pointer to Checklist. 94 | */ 95 | export interface ChecklistRef { 96 | Number: number; 97 | Owner: string; 98 | Repo: string; 99 | Stage: string; 100 | } 101 | /** 102 | * ChecklistResponse represents the JSON for a single Checklist. 103 | */ 104 | export interface ChecklistResponse { 105 | Checklist: Checklist; 106 | Me: GitHubUser; 107 | } 108 | export interface Checks { 109 | [k: string]: number[]; 110 | } 111 | /** 112 | * ErrorResponse corresponds to JSON containing error results in APIs. 113 | */ 114 | export interface ErrorResponse { 115 | Type: ErrorType; 116 | } 117 | /** 118 | * MeResponse represents the JSON for the top page. 119 | */ 120 | export interface MeResponse { 121 | Me: GitHubUser; 122 | PullRequests: { 123 | [k: string]: PullRequest[]; 124 | }; 125 | } 126 | /** 127 | * PullRequest represens a pull request on GitHub. 128 | */ 129 | export interface PullRequest { 130 | Body: string; 131 | /** 132 | * Filled for "base" pull reqs 133 | */ 134 | Commits: Commit[]; 135 | ConfigBlobID: string; 136 | IsPrivate: boolean; 137 | Number: number; 138 | Owner: string; 139 | Repo: string; 140 | Title: string; 141 | URL: string; 142 | User: GitHubUserSimple; 143 | } 144 | -------------------------------------------------------------------------------- /models_test.go: -------------------------------------------------------------------------------- 1 | package prchecklist 2 | 3 | import "testing" 4 | 5 | func TestChecksKeyFeatureNum(t *testing.T) { 6 | } 7 | 8 | func makeStubChecklist() Checklist { 9 | return Checklist{ 10 | PullRequest: &PullRequest{ 11 | Number: 1, 12 | Owner: "motemen", 13 | Repo: "test", 14 | }, 15 | Items: []*ChecklistItem{ 16 | { 17 | PullRequest: &PullRequest{Number: 2, User: GitHubUserSimple{Login: "foo"}}, 18 | CheckedBy: []GitHubUser{}, 19 | }, 20 | { 21 | PullRequest: &PullRequest{Number: 3, User: GitHubUserSimple{Login: "foo"}}, 22 | CheckedBy: []GitHubUser{}, 23 | }, 24 | { 25 | PullRequest: &PullRequest{Number: 4, User: GitHubUserSimple{Login: "bar"}}, 26 | CheckedBy: []GitHubUser{}, 27 | }, 28 | }, 29 | } 30 | } 31 | 32 | func TestChecklist_Completed(t *testing.T) { 33 | checklist := makeStubChecklist() 34 | 35 | if expected, got := false, checklist.Completed(); got != expected { 36 | t.Errorf("expected %v but got %v", expected, got) 37 | } 38 | 39 | checklist.Items[0].CheckedBy = append(checklist.Items[0].CheckedBy, GitHubUser{}) 40 | if expected, got := false, checklist.Completed(); got != expected { 41 | t.Errorf("expected %v but got %v", expected, got) 42 | } 43 | 44 | checklist.Items[0].CheckedBy = append(checklist.Items[0].CheckedBy, GitHubUser{}) 45 | if expected, got := false, checklist.Completed(); got != expected { 46 | t.Errorf("expected %v but got %v", expected, got) 47 | } 48 | 49 | checklist.Items[1].CheckedBy = append(checklist.Items[1].CheckedBy, GitHubUser{}) 50 | checklist.Items[2].CheckedBy = append(checklist.Items[1].CheckedBy, GitHubUser{}) 51 | if expected, got := true, checklist.Completed(); got != expected { 52 | t.Errorf("expected %v but got %v", expected, got) 53 | } 54 | } 55 | 56 | func TestChecklist_CompletedChecksOfUser(t *testing.T) { 57 | checklist := makeStubChecklist() 58 | 59 | if expected, got := false, checklist.CompletedChecksOfUser(GitHubUserSimple{Login: "foo"}); got != expected { 60 | t.Errorf("expected %v but got %v", expected, got) 61 | } 62 | 63 | checklist.Items[0].CheckedBy = append(checklist.Items[0].CheckedBy, GitHubUser{}) 64 | if expected, got := false, checklist.CompletedChecksOfUser(GitHubUserSimple{Login: "foo"}); got != expected { 65 | t.Errorf("expected %v but got %v", expected, got) 66 | } 67 | 68 | checklist.Items[1].CheckedBy = append(checklist.Items[1].CheckedBy, GitHubUser{}) 69 | if expected, got := true, checklist.CompletedChecksOfUser(GitHubUserSimple{Login: "foo"}); got != expected { 70 | t.Errorf("expected %v but got %v", expected, got) 71 | } 72 | if expected, got := false, checklist.CompletedChecksOfUser(GitHubUserSimple{Login: "bar"}); got != expected { 73 | t.Errorf("expected %v but got %v", expected, got) 74 | } 75 | 76 | } 77 | 78 | func TestChecklist_Item(t *testing.T) { 79 | checklist := makeStubChecklist() 80 | 81 | if item := checklist.Item(1); item != nil { 82 | t.Errorf("expected nil but got %v", item) 83 | } 84 | 85 | if item := checklist.Item(2); item == nil { 86 | t.Errorf("expected an item but got %v", item) 87 | } 88 | 89 | if item := checklist.Item(100); item != nil { 90 | t.Errorf("expected an item but got %v", item) 91 | } 92 | } 93 | 94 | func TestChecklist_Path(t *testing.T) { 95 | checklist := makeStubChecklist() 96 | 97 | if expected, got := "/motemen/test/pull/1", checklist.Path(); got != expected { 98 | t.Errorf("expected %v but got %v", expected, got) 99 | } 100 | } 101 | 102 | func TestChecklist_String(t *testing.T) { 103 | } 104 | func TestChecks_Add(t *testing.T) { 105 | } 106 | func TestChecks_Remove(t *testing.T) { 107 | } 108 | func TestChecklistRef_String(t *testing.T) { 109 | } 110 | func TestChecklistRef_Validate(t *testing.T) { 111 | } 112 | func TestGitHubUser_HTTPClient(t *testing.T) { 113 | } 114 | -------------------------------------------------------------------------------- /static/scss/app.scss: -------------------------------------------------------------------------------- 1 | @import 'node_modules/uikit/src/scss/variables.scss'; 2 | @import 'node_modules/uikit/src/scss/mixins-theme.scss'; 3 | @import 'node_modules/uikit/src/scss/uikit-theme.scss'; 4 | 5 | * { 6 | font-family: monospace; 7 | line-height: 2; 8 | } 9 | 10 | body { 11 | font-size: 18px; 12 | margin-top: 1em; 13 | color: #333; 14 | } 15 | 16 | #container { 17 | @extend .uk-flex; 18 | @extend .uk-flex-center; 19 | } 20 | 21 | #main { 22 | width: 100%; 23 | 24 | @extend .uk-width-3-5\@m; 25 | @media (min-width: $breakpoint-large) { 26 | width: 960px; 27 | } 28 | 29 | > section { 30 | margin: 1em; 31 | } 32 | } 33 | 34 | footer { 35 | font-size: small; 36 | margin: 20px 0; 37 | text-align: center; 38 | } 39 | 40 | h1 { 41 | font-size: 1.5rem; 42 | border-bottom: 2px dashed; 43 | } 44 | 45 | pre { 46 | font-size: 75%; 47 | background-color: #F3F3F3; 48 | padding: 1.5em; 49 | margin: 1.5em 0; 50 | } 51 | 52 | ul { 53 | list-style: none; 54 | padding: 0; 55 | margin: 0; 56 | } 57 | 58 | .user img { 59 | @extend .uk-border-circle; 60 | height: 28px; 61 | float: right; 62 | margin-left: 3px; 63 | } 64 | 65 | nav { 66 | @extend .uk-grid; 67 | } 68 | 69 | nav .logo { 70 | margin-bottom: 10px; 71 | } 72 | 73 | #checklist-items { 74 | li { 75 | @extend .uk-grid; 76 | @extend .uk-grid-collapse; 77 | margin-bottom: 0.75em; 78 | 79 | .title { 80 | @extend .uk-text-truncate; 81 | @extend .uk-width-expand; 82 | } 83 | .title, > .user { 84 | margin: 0 0.5em; 85 | } 86 | > .user { 87 | @extend .uk-text-small; 88 | @extend .uk-text-muted; 89 | line-height: inherit; 90 | } 91 | } 92 | .check .checkbox { 93 | border: 0; 94 | padding: 5px; 95 | background: transparent; 96 | color: #DDD; 97 | margin-right: 18px; 98 | vertical-align: middle; 99 | font-size: 28px; 100 | cursor: pointer; 101 | &:focus { 102 | outline: none; 103 | color: darken(#DDD, 10%); 104 | } 105 | &.checked { 106 | color: $global-link-color; 107 | &:focus { 108 | color: lighten($global-link-color, 10%); 109 | } 110 | } 111 | -webkit-transition: 0.1s ease-in-out; 112 | transition: 0.1s ease-in-out; 113 | -webkit-transition-property: color, background-color, border-color; 114 | transition-property: color, background-color, border-color; 115 | } 116 | } 117 | 118 | nav .logo strong { 119 | background-color: #333; 120 | color: #FFF; 121 | padding: 0 0.75em; 122 | height: 40px; 123 | display: inline-block; 124 | line-height: 40px; 125 | vertical-align: middle; 126 | .completed & { 127 | background-color: limegreen; 128 | } 129 | -webkit-transition: 0.1s ease-in-out; 130 | transition: 0.1s ease-in-out; 131 | -webkit-transition-property: color, background-color, border-color; 132 | transition-property: color, background-color, border-color; 133 | 134 | .lock-icon { 135 | margin-left: 0.5em; 136 | } 137 | } 138 | 139 | nav .user-signedin { 140 | font-size: 80%; 141 | text-decoration: none; 142 | line-height: 40px; 143 | } 144 | 145 | nav .user-signedin:before { 146 | content: "|"; 147 | margin-right: 0.5em; 148 | color: #444; 149 | } 150 | 151 | nav .stages { 152 | @extend .uk-width-expand; 153 | min-width: 150px; 154 | 155 | select { 156 | @extend .uk-select; 157 | @extend .uk-form-width-medium; 158 | font-size: inherit; 159 | border: 2px solid; 160 | } 161 | } 162 | 163 | #index-pullRequets { 164 | margin-top: 40px; 165 | h2 { 166 | margin-top: 20px; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /lib/mocks/github_gateway.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/motemen/prchecklist/v2/lib/usecase (interfaces: GitHubGateway) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | prchecklist "github.com/motemen/prchecklist/v2" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockGitHubGateway is a mock of GitHubGateway interface 15 | type MockGitHubGateway struct { 16 | ctrl *gomock.Controller 17 | recorder *MockGitHubGatewayMockRecorder 18 | } 19 | 20 | // MockGitHubGatewayMockRecorder is the mock recorder for MockGitHubGateway 21 | type MockGitHubGatewayMockRecorder struct { 22 | mock *MockGitHubGateway 23 | } 24 | 25 | // NewMockGitHubGateway creates a new mock instance 26 | func NewMockGitHubGateway(ctrl *gomock.Controller) *MockGitHubGateway { 27 | mock := &MockGitHubGateway{ctrl: ctrl} 28 | mock.recorder = &MockGitHubGatewayMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockGitHubGateway) EXPECT() *MockGitHubGatewayMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // GetBlob mocks base method 38 | func (m *MockGitHubGateway) GetBlob(arg0 context.Context, arg1 prchecklist.ChecklistRef, arg2 string) ([]byte, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "GetBlob", arg0, arg1, arg2) 41 | ret0, _ := ret[0].([]byte) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // GetBlob indicates an expected call of GetBlob 47 | func (mr *MockGitHubGatewayMockRecorder) GetBlob(arg0, arg1, arg2 interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlob", reflect.TypeOf((*MockGitHubGateway)(nil).GetBlob), arg0, arg1, arg2) 50 | } 51 | 52 | // GetPullRequest mocks base method 53 | func (m *MockGitHubGateway) GetPullRequest(arg0 context.Context, arg1 prchecklist.ChecklistRef, arg2 bool) (*prchecklist.PullRequest, context.Context, error) { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "GetPullRequest", arg0, arg1, arg2) 56 | ret0, _ := ret[0].(*prchecklist.PullRequest) 57 | ret1, _ := ret[1].(context.Context) 58 | ret2, _ := ret[2].(error) 59 | return ret0, ret1, ret2 60 | } 61 | 62 | // GetPullRequest indicates an expected call of GetPullRequest 63 | func (mr *MockGitHubGatewayMockRecorder) GetPullRequest(arg0, arg1, arg2 interface{}) *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequest", reflect.TypeOf((*MockGitHubGateway)(nil).GetPullRequest), arg0, arg1, arg2) 66 | } 67 | 68 | // GetRecentPullRequests mocks base method 69 | func (m *MockGitHubGateway) GetRecentPullRequests(arg0 context.Context) (map[string][]*prchecklist.PullRequest, error) { 70 | m.ctrl.T.Helper() 71 | ret := m.ctrl.Call(m, "GetRecentPullRequests", arg0) 72 | ret0, _ := ret[0].(map[string][]*prchecklist.PullRequest) 73 | ret1, _ := ret[1].(error) 74 | return ret0, ret1 75 | } 76 | 77 | // GetRecentPullRequests indicates an expected call of GetRecentPullRequests 78 | func (mr *MockGitHubGatewayMockRecorder) GetRecentPullRequests(arg0 interface{}) *gomock.Call { 79 | mr.mock.ctrl.T.Helper() 80 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRecentPullRequests", reflect.TypeOf((*MockGitHubGateway)(nil).GetRecentPullRequests), arg0) 81 | } 82 | 83 | // SetRepositoryStatusAs mocks base method 84 | func (m *MockGitHubGateway) SetRepositoryStatusAs(arg0 context.Context, arg1, arg2, arg3, arg4, arg5, arg6 string) error { 85 | m.ctrl.T.Helper() 86 | ret := m.ctrl.Call(m, "SetRepositoryStatusAs", arg0, arg1, arg2, arg3, arg4, arg5, arg6) 87 | ret0, _ := ret[0].(error) 88 | return ret0 89 | } 90 | 91 | // SetRepositoryStatusAs indicates an expected call of SetRepositoryStatusAs 92 | func (mr *MockGitHubGatewayMockRecorder) SetRepositoryStatusAs(arg0, arg1, arg2, arg3, arg4, arg5, arg6 interface{}) *gomock.Call { 93 | mr.mock.ctrl.T.Helper() 94 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRepositoryStatusAs", reflect.TypeOf((*MockGitHubGateway)(nil).SetRepositoryStatusAs), arg0, arg1, arg2, arg3, arg4, arg5, arg6) 95 | } 96 | -------------------------------------------------------------------------------- /lib/usecase/github_mock_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/motemen/prchecklist/v2/lib/usecase (interfaces: GitHubGateway) 3 | 4 | // Package usecase is a generated GoMock package. 5 | package usecase 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | prchecklist "github.com/motemen/prchecklist/v2" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockGitHubGateway is a mock of GitHubGateway interface 15 | type MockGitHubGateway struct { 16 | ctrl *gomock.Controller 17 | recorder *MockGitHubGatewayMockRecorder 18 | } 19 | 20 | // MockGitHubGatewayMockRecorder is the mock recorder for MockGitHubGateway 21 | type MockGitHubGatewayMockRecorder struct { 22 | mock *MockGitHubGateway 23 | } 24 | 25 | // NewMockGitHubGateway creates a new mock instance 26 | func NewMockGitHubGateway(ctrl *gomock.Controller) *MockGitHubGateway { 27 | mock := &MockGitHubGateway{ctrl: ctrl} 28 | mock.recorder = &MockGitHubGatewayMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockGitHubGateway) EXPECT() *MockGitHubGatewayMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // GetBlob mocks base method 38 | func (m *MockGitHubGateway) GetBlob(arg0 context.Context, arg1 prchecklist.ChecklistRef, arg2 string) ([]byte, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "GetBlob", arg0, arg1, arg2) 41 | ret0, _ := ret[0].([]byte) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // GetBlob indicates an expected call of GetBlob 47 | func (mr *MockGitHubGatewayMockRecorder) GetBlob(arg0, arg1, arg2 interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlob", reflect.TypeOf((*MockGitHubGateway)(nil).GetBlob), arg0, arg1, arg2) 50 | } 51 | 52 | // GetPullRequest mocks base method 53 | func (m *MockGitHubGateway) GetPullRequest(arg0 context.Context, arg1 prchecklist.ChecklistRef, arg2 bool) (*prchecklist.PullRequest, context.Context, error) { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "GetPullRequest", arg0, arg1, arg2) 56 | ret0, _ := ret[0].(*prchecklist.PullRequest) 57 | ret1, _ := ret[1].(context.Context) 58 | ret2, _ := ret[2].(error) 59 | return ret0, ret1, ret2 60 | } 61 | 62 | // GetPullRequest indicates an expected call of GetPullRequest 63 | func (mr *MockGitHubGatewayMockRecorder) GetPullRequest(arg0, arg1, arg2 interface{}) *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequest", reflect.TypeOf((*MockGitHubGateway)(nil).GetPullRequest), arg0, arg1, arg2) 66 | } 67 | 68 | // GetRecentPullRequests mocks base method 69 | func (m *MockGitHubGateway) GetRecentPullRequests(arg0 context.Context) (map[string][]*prchecklist.PullRequest, error) { 70 | m.ctrl.T.Helper() 71 | ret := m.ctrl.Call(m, "GetRecentPullRequests", arg0) 72 | ret0, _ := ret[0].(map[string][]*prchecklist.PullRequest) 73 | ret1, _ := ret[1].(error) 74 | return ret0, ret1 75 | } 76 | 77 | // GetRecentPullRequests indicates an expected call of GetRecentPullRequests 78 | func (mr *MockGitHubGatewayMockRecorder) GetRecentPullRequests(arg0 interface{}) *gomock.Call { 79 | mr.mock.ctrl.T.Helper() 80 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRecentPullRequests", reflect.TypeOf((*MockGitHubGateway)(nil).GetRecentPullRequests), arg0) 81 | } 82 | 83 | // SetRepositoryStatusAs mocks base method 84 | func (m *MockGitHubGateway) SetRepositoryStatusAs(arg0 context.Context, arg1, arg2, arg3, arg4, arg5, arg6 string) error { 85 | m.ctrl.T.Helper() 86 | ret := m.ctrl.Call(m, "SetRepositoryStatusAs", arg0, arg1, arg2, arg3, arg4, arg5, arg6) 87 | ret0, _ := ret[0].(error) 88 | return ret0 89 | } 90 | 91 | // SetRepositoryStatusAs indicates an expected call of SetRepositoryStatusAs 92 | func (mr *MockGitHubGatewayMockRecorder) SetRepositoryStatusAs(arg0, arg1, arg2, arg3, arg4, arg5, arg6 interface{}) *gomock.Call { 93 | mr.mock.ctrl.T.Helper() 94 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRepositoryStatusAs", reflect.TypeOf((*MockGitHubGateway)(nil).SetRepositoryStatusAs), arg0, arg1, arg2, arg3, arg4, arg5, arg6) 95 | } 96 | -------------------------------------------------------------------------------- /lib/usecase/notification.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/pkg/errors" 12 | 13 | "github.com/motemen/go-nuts/httputil" 14 | "github.com/motemen/prchecklist/v2" 15 | ) 16 | 17 | type eventType int 18 | 19 | const ( 20 | eventTypeInvalid eventType = iota 21 | eventTypeOnCheck 22 | eventTypeOnComplete 23 | eventTypeOnCompleteChecksOfUser 24 | eventTypeOnRemove 25 | ) 26 | 27 | type notificationEvent interface { 28 | slackMessageText(ctx context.Context) string 29 | eventType() eventType 30 | } 31 | 32 | type removeCheckEvent struct { 33 | checklist *prchecklist.Checklist 34 | item *prchecklist.ChecklistItem 35 | user prchecklist.GitHubUser 36 | } 37 | 38 | func (e removeCheckEvent) slackMessageText(ctx context.Context) string { 39 | u := prchecklist.BuildURL(ctx, e.checklist.Path()).String() 40 | return fmt.Sprintf("[<%s|%s>] #%d %q check removed by %s", u, e.checklist, e.item.Number, e.item.Title, e.user.Login) 41 | } 42 | 43 | func (e removeCheckEvent) eventType() eventType { 44 | return eventTypeOnRemove 45 | } 46 | 47 | type addCheckEvent struct { 48 | checklist *prchecklist.Checklist 49 | item *prchecklist.ChecklistItem 50 | user prchecklist.GitHubUser 51 | } 52 | 53 | func (e addCheckEvent) slackMessageText(ctx context.Context) string { 54 | u := prchecklist.BuildURL(ctx, e.checklist.Path()).String() 55 | return fmt.Sprintf("[<%s|%s>] #%d %q checked by %s", u, e.checklist, e.item.Number, e.item.Title, e.user.Login) 56 | } 57 | 58 | func (e addCheckEvent) eventType() eventType { return eventTypeOnCheck } 59 | 60 | type completeEvent struct { 61 | checklist *prchecklist.Checklist 62 | } 63 | 64 | func (e completeEvent) slackMessageText(ctx context.Context) string { 65 | u := prchecklist.BuildURL(ctx, e.checklist.Path()).String() 66 | return fmt.Sprintf("[<%s|%s>] Checklist completed! :tada:", u, e.checklist) 67 | } 68 | 69 | func (e completeEvent) eventType() eventType { return eventTypeOnComplete } 70 | 71 | type completeChecksOfUserEvent struct { 72 | checklist *prchecklist.Checklist 73 | user prchecklist.GitHubUserSimple 74 | } 75 | 76 | func (e completeChecksOfUserEvent) slackMessageText(ctx context.Context) string { 77 | u := prchecklist.BuildURL(ctx, e.checklist.Path()).String() 78 | return fmt.Sprintf("[<%s|%s>] completed checks of %s", u, e.checklist, e.user.Login) 79 | } 80 | 81 | func (e completeChecksOfUserEvent) eventType() eventType { return eventTypeOnCompleteChecksOfUser } 82 | 83 | func (u Usecase) notifyEvent(ctx context.Context, checklist *prchecklist.Checklist, event notificationEvent) error { 84 | config := checklist.Config 85 | if config == nil { 86 | return nil 87 | } 88 | 89 | var chNames []string 90 | switch event.eventType() { 91 | case eventTypeOnRemove: 92 | chNames = config.Notification.Events.OnRemove 93 | case eventTypeOnCheck: 94 | chNames = config.Notification.Events.OnCheck 95 | case eventTypeOnCompleteChecksOfUser: 96 | chNames = config.Notification.Events.OnCompleteChecksOfUser 97 | case eventTypeOnComplete: 98 | chNames = config.Notification.Events.OnComplete 99 | lastCommitID := checklist.Commits[len(checklist.Commits)-1].Oid 100 | if err := u.setRepositoryCompletedStatusAs(ctx, checklist.Owner, checklist.Repo, lastCommitID, "success", checklist.Stage, prchecklist.BuildURL(ctx, checklist.Path()).String()); err != nil { 101 | log.Printf("Failed to SetRepositoryStatusAs: %s (%+v)", err, err) 102 | } 103 | default: 104 | return errors.Errorf("unknown event type: %v", event.eventType()) 105 | } 106 | 107 | for _, name := range chNames { 108 | name := name 109 | ch, ok := config.Notification.Channels[name] 110 | if !ok { 111 | continue 112 | } 113 | 114 | go func() { 115 | payload, err := json.Marshal(&slackMessagePayload{ 116 | Text: event.slackMessageText(ctx), 117 | }) 118 | if err != nil { 119 | log.Printf("json.Marshal: %s", err) 120 | return 121 | } 122 | 123 | v := url.Values{"payload": {string(payload)}} 124 | 125 | _, err = httputil.Successful(http.PostForm(ch.URL, v)) 126 | if err != nil { 127 | log.Printf("posting Slack webhook: %s", err) 128 | return 129 | } 130 | }() 131 | } 132 | 133 | return nil 134 | } 135 | 136 | type slackMessagePayload struct { 137 | Text string `json:"text"` 138 | } 139 | -------------------------------------------------------------------------------- /lib/mocks/core_repository.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/motemen/prchecklist/v2/lib/usecase (interfaces: CoreRepository) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | prchecklist "github.com/motemen/prchecklist/v2" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockCoreRepository is a mock of CoreRepository interface 15 | type MockCoreRepository struct { 16 | ctrl *gomock.Controller 17 | recorder *MockCoreRepositoryMockRecorder 18 | } 19 | 20 | // MockCoreRepositoryMockRecorder is the mock recorder for MockCoreRepository 21 | type MockCoreRepositoryMockRecorder struct { 22 | mock *MockCoreRepository 23 | } 24 | 25 | // NewMockCoreRepository creates a new mock instance 26 | func NewMockCoreRepository(ctrl *gomock.Controller) *MockCoreRepository { 27 | mock := &MockCoreRepository{ctrl: ctrl} 28 | mock.recorder = &MockCoreRepositoryMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockCoreRepository) EXPECT() *MockCoreRepositoryMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // AddCheck mocks base method 38 | func (m *MockCoreRepository) AddCheck(arg0 context.Context, arg1 prchecklist.ChecklistRef, arg2 string, arg3 prchecklist.GitHubUser) error { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "AddCheck", arg0, arg1, arg2, arg3) 41 | ret0, _ := ret[0].(error) 42 | return ret0 43 | } 44 | 45 | // AddCheck indicates an expected call of AddCheck 46 | func (mr *MockCoreRepositoryMockRecorder) AddCheck(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddCheck", reflect.TypeOf((*MockCoreRepository)(nil).AddCheck), arg0, arg1, arg2, arg3) 49 | } 50 | 51 | // AddUser mocks base method 52 | func (m *MockCoreRepository) AddUser(arg0 context.Context, arg1 prchecklist.GitHubUser) error { 53 | m.ctrl.T.Helper() 54 | ret := m.ctrl.Call(m, "AddUser", arg0, arg1) 55 | ret0, _ := ret[0].(error) 56 | return ret0 57 | } 58 | 59 | // AddUser indicates an expected call of AddUser 60 | func (mr *MockCoreRepositoryMockRecorder) AddUser(arg0, arg1 interface{}) *gomock.Call { 61 | mr.mock.ctrl.T.Helper() 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUser", reflect.TypeOf((*MockCoreRepository)(nil).AddUser), arg0, arg1) 63 | } 64 | 65 | // GetChecks mocks base method 66 | func (m *MockCoreRepository) GetChecks(arg0 context.Context, arg1 prchecklist.ChecklistRef) (prchecklist.Checks, error) { 67 | m.ctrl.T.Helper() 68 | ret := m.ctrl.Call(m, "GetChecks", arg0, arg1) 69 | ret0, _ := ret[0].(prchecklist.Checks) 70 | ret1, _ := ret[1].(error) 71 | return ret0, ret1 72 | } 73 | 74 | // GetChecks indicates an expected call of GetChecks 75 | func (mr *MockCoreRepositoryMockRecorder) GetChecks(arg0, arg1 interface{}) *gomock.Call { 76 | mr.mock.ctrl.T.Helper() 77 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChecks", reflect.TypeOf((*MockCoreRepository)(nil).GetChecks), arg0, arg1) 78 | } 79 | 80 | // GetUsers mocks base method 81 | func (m *MockCoreRepository) GetUsers(arg0 context.Context, arg1 []int) (map[int]prchecklist.GitHubUser, error) { 82 | m.ctrl.T.Helper() 83 | ret := m.ctrl.Call(m, "GetUsers", arg0, arg1) 84 | ret0, _ := ret[0].(map[int]prchecklist.GitHubUser) 85 | ret1, _ := ret[1].(error) 86 | return ret0, ret1 87 | } 88 | 89 | // GetUsers indicates an expected call of GetUsers 90 | func (mr *MockCoreRepositoryMockRecorder) GetUsers(arg0, arg1 interface{}) *gomock.Call { 91 | mr.mock.ctrl.T.Helper() 92 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsers", reflect.TypeOf((*MockCoreRepository)(nil).GetUsers), arg0, arg1) 93 | } 94 | 95 | // RemoveCheck mocks base method 96 | func (m *MockCoreRepository) RemoveCheck(arg0 context.Context, arg1 prchecklist.ChecklistRef, arg2 string, arg3 prchecklist.GitHubUser) error { 97 | m.ctrl.T.Helper() 98 | ret := m.ctrl.Call(m, "RemoveCheck", arg0, arg1, arg2, arg3) 99 | ret0, _ := ret[0].(error) 100 | return ret0 101 | } 102 | 103 | // RemoveCheck indicates an expected call of RemoveCheck 104 | func (mr *MockCoreRepositoryMockRecorder) RemoveCheck(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { 105 | mr.mock.ctrl.T.Helper() 106 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveCheck", reflect.TypeOf((*MockCoreRepository)(nil).RemoveCheck), arg0, arg1, arg2, arg3) 107 | } 108 | -------------------------------------------------------------------------------- /lib/repository_mock/core.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/motemen/prchecklist/v2/lib/usecase (interfaces: CoreRepository) 3 | 4 | // Package repository_mock is a generated GoMock package. 5 | package repository_mock 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | prchecklist "github.com/motemen/prchecklist/v2" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockCoreRepository is a mock of CoreRepository interface 15 | type MockCoreRepository struct { 16 | ctrl *gomock.Controller 17 | recorder *MockCoreRepositoryMockRecorder 18 | } 19 | 20 | // MockCoreRepositoryMockRecorder is the mock recorder for MockCoreRepository 21 | type MockCoreRepositoryMockRecorder struct { 22 | mock *MockCoreRepository 23 | } 24 | 25 | // NewMockCoreRepository creates a new mock instance 26 | func NewMockCoreRepository(ctrl *gomock.Controller) *MockCoreRepository { 27 | mock := &MockCoreRepository{ctrl: ctrl} 28 | mock.recorder = &MockCoreRepositoryMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockCoreRepository) EXPECT() *MockCoreRepositoryMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // AddCheck mocks base method 38 | func (m *MockCoreRepository) AddCheck(arg0 context.Context, arg1 prchecklist.ChecklistRef, arg2 string, arg3 prchecklist.GitHubUser) error { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "AddCheck", arg0, arg1, arg2, arg3) 41 | ret0, _ := ret[0].(error) 42 | return ret0 43 | } 44 | 45 | // AddCheck indicates an expected call of AddCheck 46 | func (mr *MockCoreRepositoryMockRecorder) AddCheck(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddCheck", reflect.TypeOf((*MockCoreRepository)(nil).AddCheck), arg0, arg1, arg2, arg3) 49 | } 50 | 51 | // AddUser mocks base method 52 | func (m *MockCoreRepository) AddUser(arg0 context.Context, arg1 prchecklist.GitHubUser) error { 53 | m.ctrl.T.Helper() 54 | ret := m.ctrl.Call(m, "AddUser", arg0, arg1) 55 | ret0, _ := ret[0].(error) 56 | return ret0 57 | } 58 | 59 | // AddUser indicates an expected call of AddUser 60 | func (mr *MockCoreRepositoryMockRecorder) AddUser(arg0, arg1 interface{}) *gomock.Call { 61 | mr.mock.ctrl.T.Helper() 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUser", reflect.TypeOf((*MockCoreRepository)(nil).AddUser), arg0, arg1) 63 | } 64 | 65 | // GetChecks mocks base method 66 | func (m *MockCoreRepository) GetChecks(arg0 context.Context, arg1 prchecklist.ChecklistRef) (prchecklist.Checks, error) { 67 | m.ctrl.T.Helper() 68 | ret := m.ctrl.Call(m, "GetChecks", arg0, arg1) 69 | ret0, _ := ret[0].(prchecklist.Checks) 70 | ret1, _ := ret[1].(error) 71 | return ret0, ret1 72 | } 73 | 74 | // GetChecks indicates an expected call of GetChecks 75 | func (mr *MockCoreRepositoryMockRecorder) GetChecks(arg0, arg1 interface{}) *gomock.Call { 76 | mr.mock.ctrl.T.Helper() 77 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChecks", reflect.TypeOf((*MockCoreRepository)(nil).GetChecks), arg0, arg1) 78 | } 79 | 80 | // GetUsers mocks base method 81 | func (m *MockCoreRepository) GetUsers(arg0 context.Context, arg1 []int) (map[int]prchecklist.GitHubUser, error) { 82 | m.ctrl.T.Helper() 83 | ret := m.ctrl.Call(m, "GetUsers", arg0, arg1) 84 | ret0, _ := ret[0].(map[int]prchecklist.GitHubUser) 85 | ret1, _ := ret[1].(error) 86 | return ret0, ret1 87 | } 88 | 89 | // GetUsers indicates an expected call of GetUsers 90 | func (mr *MockCoreRepositoryMockRecorder) GetUsers(arg0, arg1 interface{}) *gomock.Call { 91 | mr.mock.ctrl.T.Helper() 92 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsers", reflect.TypeOf((*MockCoreRepository)(nil).GetUsers), arg0, arg1) 93 | } 94 | 95 | // RemoveCheck mocks base method 96 | func (m *MockCoreRepository) RemoveCheck(arg0 context.Context, arg1 prchecklist.ChecklistRef, arg2 string, arg3 prchecklist.GitHubUser) error { 97 | m.ctrl.T.Helper() 98 | ret := m.ctrl.Call(m, "RemoveCheck", arg0, arg1, arg2, arg3) 99 | ret0, _ := ret[0].(error) 100 | return ret0 101 | } 102 | 103 | // RemoveCheck indicates an expected call of RemoveCheck 104 | func (mr *MockCoreRepositoryMockRecorder) RemoveCheck(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { 105 | mr.mock.ctrl.T.Helper() 106 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveCheck", reflect.TypeOf((*MockCoreRepository)(nil).RemoveCheck), arg0, arg1, arg2, arg3) 107 | } 108 | -------------------------------------------------------------------------------- /lib/usecase/usecase_test.go: -------------------------------------------------------------------------------- 1 | //go:generate go run github.com/golang/mock/mockgen -package mocks -destination ../mocks/core_repository.go . CoreRepository 2 | //go:generate go run github.com/golang/mock/mockgen -package mocks -destination ../mocks/github_gateway.go . GitHubGateway 3 | 4 | package usecase 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/golang/mock/gomock" 11 | "github.com/stretchr/testify/assert" 12 | 13 | prchecklist "github.com/motemen/prchecklist/v2" 14 | "github.com/motemen/prchecklist/v2/lib/repository_mock" 15 | ) 16 | 17 | func TestUseCase_GetChecklist(t *testing.T) { 18 | ctrl := gomock.NewController(t) 19 | defer ctrl.Finish() 20 | 21 | repo := repository_mock.NewMockCoreRepository(ctrl) 22 | github := NewMockGitHubGateway(ctrl) 23 | 24 | clRef := prchecklist.ChecklistRef{Owner: "test", Repo: "test", Number: 1, Stage: "default"} 25 | 26 | github.EXPECT().GetPullRequest( 27 | gomock.Any(), 28 | clRef, 29 | true, 30 | ).Return(&prchecklist.PullRequest{ 31 | Owner: "test", 32 | Repo: "test", 33 | Commits: []prchecklist.Commit{ 34 | {Message: "Merge pull request #2 "}, 35 | }, 36 | ConfigBlobID: "DUMMY-CONFIG-BLOB-ID", 37 | }, context.Background(), nil) 38 | 39 | github.EXPECT().GetPullRequest( 40 | gomock.Any(), 41 | prchecklist.ChecklistRef{Owner: "test", Repo: "test", Number: 2}, 42 | false, 43 | ).Return(&prchecklist.PullRequest{}, context.Background(), nil) 44 | 45 | github.EXPECT().GetBlob( 46 | gomock.Any(), 47 | clRef, 48 | "DUMMY-CONFIG-BLOB-ID", 49 | ).Return( 50 | []byte(`--- 51 | stages: 52 | - qa 53 | - production 54 | `), 55 | nil, 56 | ) 57 | 58 | repo.EXPECT().GetChecks(gomock.Any(), clRef). 59 | Return(prchecklist.Checks{}, nil) 60 | 61 | repo.EXPECT().GetUsers(gomock.Any(), gomock.Len(0)). 62 | Return(map[int]prchecklist.GitHubUser{}, nil) 63 | 64 | app := New(github, repo) 65 | ctx := context.Background() 66 | 67 | cl, err := app.GetChecklist(ctx, clRef) 68 | 69 | assert.NoError(t, err) 70 | assert.Equal( 71 | t, cl.Config.Stages, []string{"qa", "production"}, 72 | ) 73 | } 74 | 75 | func setupMocks(clRef prchecklist.ChecklistRef, github *MockGitHubGateway, repo *repository_mock.MockCoreRepository) { 76 | github.EXPECT().GetPullRequest( 77 | gomock.Any(), 78 | clRef, 79 | true, 80 | ).Return(&prchecklist.PullRequest{ 81 | Owner: "test", 82 | Repo: "test", 83 | Commits: []prchecklist.Commit{ 84 | {Message: "Merge pull request #2 "}, 85 | {Message: "Merge pull request #3 "}, 86 | }, 87 | }, context.Background(), nil) 88 | 89 | github.EXPECT().GetPullRequest( 90 | gomock.Any(), 91 | prchecklist.ChecklistRef{Owner: "test", Repo: "test", Number: 2}, 92 | false, 93 | ).Return(&prchecklist.PullRequest{ 94 | Number: 2, 95 | }, context.Background(), nil) 96 | 97 | github.EXPECT().GetPullRequest( 98 | gomock.Any(), 99 | prchecklist.ChecklistRef{Owner: "test", Repo: "test", Number: 3}, 100 | false, 101 | ).Return(&prchecklist.PullRequest{ 102 | Number: 3, 103 | }, context.Background(), nil) 104 | 105 | repo.EXPECT().GetChecks(gomock.Any(), clRef). 106 | Return(prchecklist.Checks{}, nil) 107 | 108 | repo.EXPECT().GetUsers(gomock.Any(), gomock.Len(0)). 109 | Return(map[int]prchecklist.GitHubUser{}, nil) 110 | } 111 | 112 | func TestUsecase_AddCheck(t *testing.T) { 113 | ctrl := gomock.NewController(t) 114 | defer ctrl.Finish() 115 | 116 | clRef := prchecklist.ChecklistRef{Owner: "test", Repo: "test", Number: 1, Stage: "default"} 117 | repo := repository_mock.NewMockCoreRepository(ctrl) 118 | github := NewMockGitHubGateway(ctrl) 119 | 120 | setupMocks(clRef, github, repo) 121 | 122 | repo.EXPECT().AddCheck( 123 | gomock.Any(), 124 | clRef, 125 | "2", 126 | gomock.Any(), 127 | ) 128 | 129 | app := New(github, repo) 130 | ctx := context.Background() 131 | 132 | cl, err := app.AddCheck( 133 | ctx, 134 | clRef, 135 | 2, 136 | prchecklist.GitHubUser{ 137 | ID: 1, 138 | Login: "test", 139 | }, 140 | ) 141 | 142 | assert.NoError(t, err) 143 | t.Log(cl) 144 | } 145 | 146 | func TestUsecase_RemoveCheck(t *testing.T) { 147 | ctrl := gomock.NewController(t) 148 | defer ctrl.Finish() 149 | 150 | clRef := prchecklist.ChecklistRef{Owner: "test", Repo: "test", Number: 1, Stage: "default"} 151 | repo := repository_mock.NewMockCoreRepository(ctrl) 152 | github := NewMockGitHubGateway(ctrl) 153 | 154 | setupMocks(clRef, github, repo) 155 | 156 | repo.EXPECT().RemoveCheck( 157 | gomock.Any(), 158 | clRef, 159 | "2", 160 | gomock.Any(), 161 | ) 162 | 163 | app := New(github, repo) 164 | ctx := context.Background() 165 | 166 | cl, err := app.RemoveCheck( 167 | ctx, 168 | clRef, 169 | 2, 170 | prchecklist.GitHubUser{ 171 | ID: 1, 172 | Login: "test", 173 | }, 174 | ) 175 | 176 | assert.NoError(t, err) 177 | t.Log(cl) 178 | } 179 | -------------------------------------------------------------------------------- /lib/web/web_test.go: -------------------------------------------------------------------------------- 1 | //go:generate go run github.com/golang/mock/mockgen -package web -destination web_mock_test.go github.com/motemen/prchecklist/v2/lib/web GitHubGateway 2 | package web 3 | 4 | import ( 5 | "os" 6 | "testing" 7 | 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | 12 | "github.com/golang/mock/gomock" 13 | "github.com/motemen/go-nuts/httputil" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | var noRedirectClient = http.Client{ 18 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 19 | return http.ErrUseLastResponse 20 | }, 21 | } 22 | 23 | func TestWeb_HandleAuth(t *testing.T) { 24 | ctrl := gomock.NewController(t) 25 | 26 | g := NewMockGitHubGateway(ctrl) 27 | 28 | web := New(nil, g) 29 | s := httptest.NewServer(web.Handler()) 30 | defer s.Close() 31 | 32 | u, _ := url.Parse(s.URL) 33 | u.Path = "/auth/callback" 34 | u.RawQuery = url.Values{"return_to": {"/motemen/test-repository/pull/2"}}.Encode() 35 | 36 | g.EXPECT().AuthCodeURL(gomock.Any(), u).Return("http://github-auth-stub") 37 | 38 | resp, err := noRedirectClient.Get(s.URL + "/auth?return_to=/motemen/test-repository/pull/2") 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | if got, expected := resp.Header.Get("Location"), "http://github-auth-stub"; got != expected { 44 | t.Fatalf("Location header differs: %v != %v", got, expected) 45 | } 46 | } 47 | 48 | func TestWeb_Static(t *testing.T) { 49 | ctrl := gomock.NewController(t) 50 | 51 | g := NewMockGitHubGateway(ctrl) 52 | 53 | web := New(nil, g) 54 | s := httptest.NewServer(web.Handler()) 55 | defer s.Close() 56 | 57 | _, err := httputil.Successful(http.Get(s.URL + "/js/bundle.js")) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | } 62 | 63 | func TestWeb_HandleAuth_forward(t *testing.T) { 64 | type testServer struct { 65 | mock *gomock.Controller 66 | github *MockGitHubGateway 67 | web *Web 68 | server *httptest.Server 69 | } 70 | 71 | build := func() testServer { 72 | ctrl := gomock.NewController(t) 73 | g := NewMockGitHubGateway(ctrl) 74 | web := New(nil, g) 75 | s := httptest.NewServer(web.Handler()) 76 | return testServer{ 77 | mock: ctrl, 78 | github: g, 79 | web: web, 80 | server: s, 81 | } 82 | } 83 | 84 | mainApp := build() 85 | 86 | mainAppURL, _ := url.Parse(mainApp.server.URL) 87 | os.Setenv("PRCHECKLIST_OAUTH_CALLBACK_ORIGIN", mainAppURL.Scheme+"://"+mainAppURL.Host) 88 | reviewApp := build() 89 | os.Setenv("PRCHECKLIST_OAUTH_CALLBACK_ORIGIN", "") 90 | 91 | defer mainApp.server.Close() 92 | defer reviewApp.server.Close() 93 | 94 | reviewApp.github.EXPECT().AuthCodeURL(gomock.Any(), gomock.Any()).DoAndReturn(func(state string, redirectURI *url.URL) string { 95 | return "http://github-auth-stub?redirect_uri=" + url.QueryEscape(redirectURI.String()) 96 | }) 97 | 98 | var redirectURI *url.URL 99 | 100 | t.Run("GET /auth to review app redirects to main app", func(t *testing.T) { 101 | resp, err := noRedirectClient.Get(reviewApp.server.URL + "/auth?return_to=/motemen/test-repository/pull/2") 102 | require.NoError(t, err) 103 | 104 | // 1. Location: header is a URL to GitHub OAuth authz page 105 | // 2. with redirect_uri set to main app's auth page 106 | // 3. forwarding to review app's auth callback 107 | // 4. with return_to set to original return_to param. 108 | 109 | // 1. 110 | location, err := url.Parse(resp.Header.Get("Location")) 111 | require.NoError(t, err, "parsing Location") 112 | 113 | t.Logf("Location: %s", location) 114 | 115 | // 2. 116 | redirectURI, err = url.Parse(location.Query().Get("redirect_uri")) 117 | require.NoError(t, err, "parsing redirect_uri") 118 | require.Equal(t, 119 | mainApp.server.URL+"/auth/callback/forward", 120 | "http://"+redirectURI.Host+redirectURI.Path, 121 | "redirect_uri", 122 | ) 123 | 124 | // 3. 125 | forward, err := url.Parse(redirectURI.Query().Get("to")) 126 | require.NoError(t, err, "parsing forward") 127 | require.Equal(t, 128 | reviewApp.server.URL+"/auth/callback", 129 | "http://"+forward.Host+forward.Path, 130 | "forward", 131 | ) 132 | 133 | // 4. 134 | require.Equal(t, "/motemen/test-repository/pull/2", forward.Query().Get("return_to")) 135 | }) 136 | 137 | t.Run("forward succeeds", func(t *testing.T) { 138 | resp, err := noRedirectClient.Get(redirectURI.String() + "&code=STUBCODE") 139 | require.NoError(t, err) 140 | 141 | location, err := url.Parse(resp.Header.Get("Location")) 142 | require.NoError(t, err) 143 | 144 | require.Equal(t, "/motemen/test-repository/pull/2", location.Query().Get("return_to")) 145 | require.Equal(t, "STUBCODE", location.Query().Get("code")) 146 | }) 147 | 148 | t.Run("forward fails with invalid signature", func(t *testing.T) { 149 | invalidRedirectURI := *redirectURI 150 | q := invalidRedirectURI.Query() 151 | q.Set("sig", "invalid") 152 | invalidRedirectURI.RawQuery = q.Encode() 153 | 154 | resp, err := noRedirectClient.Get(invalidRedirectURI.String() + "&code=STUBCODE") 155 | require.NoError(t, err) 156 | require.True(t, resp.StatusCode >= 400) 157 | }) 158 | } 159 | -------------------------------------------------------------------------------- /lib/repository/datastore.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/pkg/errors" 8 | 9 | "cloud.google.com/go/datastore" 10 | 11 | "github.com/motemen/prchecklist/v2" 12 | ) 13 | 14 | type datastoreRepository struct { 15 | client *datastore.Client 16 | } 17 | 18 | const ( 19 | datastoreKindUser = "User" 20 | datastoreKindCheck = "Check" 21 | ) 22 | 23 | func init() { 24 | registerCoreRepositoryBuilder("datastore", NewDatastoreCore) 25 | } 26 | 27 | // NewDatastoreCore creates a coreRepository backed by Cloud Datastore. 28 | // The datasource must start with "datastore:", followed by a GCP project id. 29 | func NewDatastoreCore(datasource string) (coreRepository, error) { 30 | projectID := datasource[len("datastore:"):] 31 | client, err := datastore.NewClient(context.Background(), projectID) 32 | return &datastoreRepository{ 33 | client: client, 34 | }, err 35 | } 36 | 37 | func (r datastoreRepository) AddUser(ctx context.Context, user prchecklist.GitHubUser) error { 38 | key := datastore.IDKey(datastoreKindUser, int64(user.ID), nil) 39 | _, err := r.client.Put(ctx, key, &user) 40 | return err 41 | } 42 | 43 | func (r datastoreRepository) GetUsers(ctx context.Context, userIDs []int) (map[int]prchecklist.GitHubUser, error) { 44 | keys := make([]*datastore.Key, len(userIDs)) 45 | for i, id := range userIDs { 46 | keys[i] = datastore.IDKey(datastoreKindUser, int64(id), nil) 47 | } 48 | 49 | users := make([]prchecklist.GitHubUser, len(userIDs)) 50 | err := r.client.GetMulti(ctx, keys, users) 51 | if err != nil { 52 | return nil, errors.Wrap(err, "datastoreRepository.GetUsers") 53 | } 54 | 55 | result := map[int]prchecklist.GitHubUser{} 56 | for _, user := range users { 57 | result[user.ID] = user 58 | } 59 | 60 | return result, nil 61 | } 62 | 63 | func (r datastoreRepository) GetChecks(ctx context.Context, clRef prchecklist.ChecklistRef) (prchecklist.Checks, error) { 64 | var bridge datastoreChecksBridge 65 | key := datastore.NameKey(datastoreKindCheck, clRef.String(), nil) 66 | err := r.client.Get(ctx, key, &bridge) 67 | if err == datastore.ErrNoSuchEntity { 68 | err = nil 69 | } 70 | return bridge.checks, errors.WithStack(err) 71 | } 72 | 73 | func (r datastoreRepository) AddCheck(ctx context.Context, clRef prchecklist.ChecklistRef, key string, user prchecklist.GitHubUser) error { 74 | dbKey := datastore.NameKey(datastoreKindCheck, clRef.String(), nil) 75 | 76 | _, err := r.client.RunInTransaction(ctx, func(tx *datastore.Transaction) error { 77 | var bridge datastoreChecksBridge 78 | err := r.client.Get(ctx, dbKey, &bridge) 79 | if err != nil && err != datastore.ErrNoSuchEntity { 80 | return err 81 | } 82 | 83 | if bridge.checks == nil { 84 | bridge.checks = prchecklist.Checks{} 85 | } 86 | 87 | if bridge.checks.Add(key, user) == false { 88 | log.Printf("%#v", bridge) 89 | return nil 90 | } 91 | 92 | _, err = r.client.Put(ctx, dbKey, &bridge) 93 | return err 94 | }) 95 | 96 | return errors.WithStack(err) 97 | } 98 | 99 | func (r datastoreRepository) RemoveCheck(ctx context.Context, clRef prchecklist.ChecklistRef, key string, user prchecklist.GitHubUser) error { 100 | dbKey := datastore.NameKey(datastoreKindCheck, clRef.String(), nil) 101 | 102 | _, err := r.client.RunInTransaction(ctx, func(tx *datastore.Transaction) error { 103 | var bridge datastoreChecksBridge 104 | err := r.client.Get(ctx, dbKey, &bridge) 105 | if err != nil && err != datastore.ErrNoSuchEntity { 106 | return errors.Wrapf(err, "Get %s", dbKey) 107 | } 108 | 109 | if bridge.checks == nil { 110 | bridge.checks = prchecklist.Checks{} 111 | } 112 | 113 | if bridge.checks.Remove(key, user) == false { 114 | return nil 115 | } 116 | 117 | _, err = r.client.Put(ctx, dbKey, &bridge) 118 | return errors.Wrapf(err, "Put %s", dbKey) 119 | }) 120 | 121 | return errors.WithStack(err) 122 | } 123 | 124 | type datastoreChecksBridge struct { 125 | checks prchecklist.Checks 126 | } 127 | 128 | func (b *datastoreChecksBridge) Load(props []datastore.Property) error { 129 | if b.checks == nil { 130 | b.checks = prchecklist.Checks{} 131 | } 132 | for _, p := range props { 133 | var ok bool 134 | ifaces, ok := p.Value.([]interface{}) 135 | if !ok { 136 | return errors.Errorf("invalid type: %v", p.Value) 137 | } 138 | b.checks[p.Name] = interfaceSliceToIntSlice(ifaces) 139 | } 140 | return nil 141 | } 142 | 143 | func (b *datastoreChecksBridge) Save() ([]datastore.Property, error) { 144 | props := make([]datastore.Property, 0, len(b.checks)) 145 | for key, value := range b.checks { 146 | props = append(props, datastore.Property{ 147 | Name: key, 148 | Value: intSliceToInterfaceSlice(value), 149 | }) 150 | } 151 | return props, nil 152 | } 153 | 154 | func intSliceToInterfaceSlice(ints []int) []interface{} { 155 | ifaces := make([]interface{}, len(ints)) 156 | for i, in := range ints { 157 | ifaces[i] = int64(in) 158 | } 159 | return ifaces 160 | } 161 | 162 | func interfaceSliceToIntSlice(ifaces []interface{}) []int { 163 | ints := make([]int, len(ifaces)) 164 | for i, iface := range ifaces { 165 | switch v := iface.(type) { 166 | case int64: 167 | ints[i] = int(v) 168 | default: 169 | return nil 170 | } 171 | } 172 | return ints 173 | } 174 | -------------------------------------------------------------------------------- /lib/repository/bolt.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/boltdb/bolt" 11 | "github.com/pkg/errors" 12 | 13 | "github.com/motemen/prchecklist/v2" 14 | ) 15 | 16 | func init() { 17 | registerCoreRepositoryBuilder("bolt", NewBoltCore) 18 | } 19 | 20 | type boltCoreRepository struct { 21 | db *bolt.DB 22 | } 23 | 24 | const ( 25 | boltBucketNameUsers = "users" 26 | boltBucketNameChecks = "checks" 27 | ) 28 | 29 | // NewBoltCore creates a coreRepository backed by boltdb. 30 | // The datasource must start with "bolt:", followed by a path on the filesystem, 31 | // which passed to bolt.Open. 32 | func NewBoltCore(datasource string) (coreRepository, error) { 33 | path := datasource[len("bolt:"):] 34 | 35 | db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second}) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | err = db.Update(func(tx *bolt.Tx) error { 41 | if _, err := tx.CreateBucketIfNotExists([]byte(boltBucketNameUsers)); err != nil { 42 | return err 43 | } 44 | if _, err := tx.CreateBucketIfNotExists([]byte(boltBucketNameChecks)); err != nil { 45 | return err 46 | } 47 | return nil 48 | }) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return &boltCoreRepository{db: db}, nil 54 | } 55 | 56 | // AddUser implements coreRepository.AddUser. 57 | func (r boltCoreRepository) AddUser(ctx context.Context, user prchecklist.GitHubUser) error { 58 | return r.db.Update(func(tx *bolt.Tx) error { 59 | usersBucket := tx.Bucket([]byte(boltBucketNameUsers)) 60 | 61 | buf, err := json.Marshal(user) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | return usersBucket.Put([]byte(strconv.FormatInt(int64(user.ID), 10)), buf) 67 | }) 68 | } 69 | 70 | // GetUsers implements coreRepository.GetUser. 71 | func (r boltCoreRepository) GetUsers(ctx context.Context, userIDs []int) (map[int]prchecklist.GitHubUser, error) { 72 | users := make(map[int]prchecklist.GitHubUser, len(userIDs)) 73 | err := r.db.View(func(tx *bolt.Tx) error { 74 | usersBucket := tx.Bucket([]byte(boltBucketNameUsers)) 75 | 76 | for _, id := range userIDs { 77 | buf := usersBucket.Get([]byte(strconv.FormatInt(int64(id), 10))) 78 | if buf == nil { 79 | return fmt.Errorf("not found: user id=%v", id) 80 | } 81 | 82 | var user prchecklist.GitHubUser 83 | if err := json.Unmarshal(buf, &user); err != nil { 84 | return err 85 | } 86 | users[id] = user 87 | } 88 | 89 | return nil 90 | }) 91 | 92 | return users, errors.Wrap(err, "GetUsers") 93 | } 94 | 95 | // GetChecks implements coreRepository.GetChecks. 96 | func (r boltCoreRepository) GetChecks(ctx context.Context, clRef prchecklist.ChecklistRef) (prchecklist.Checks, error) { 97 | if err := clRef.Validate(); err != nil { 98 | return nil, err 99 | } 100 | 101 | var checks prchecklist.Checks 102 | 103 | err := r.db.View(func(tx *bolt.Tx) error { 104 | checksBucket := tx.Bucket([]byte(boltBucketNameChecks)) 105 | 106 | key := []byte(clRef.String()) 107 | data := checksBucket.Get(key) 108 | if data != nil { 109 | err := json.Unmarshal(data, &checks) 110 | if err != nil { 111 | return err 112 | } 113 | } 114 | 115 | return nil 116 | }) 117 | 118 | return checks, errors.Wrap(err, "GetChecks") 119 | } 120 | 121 | // AddCheck implements coreRepository.AddCheck. 122 | func (r boltCoreRepository) AddCheck(ctx context.Context, clRef prchecklist.ChecklistRef, key string, user prchecklist.GitHubUser) error { 123 | if err := clRef.Validate(); err != nil { 124 | return err 125 | } 126 | 127 | return r.db.Update(func(tx *bolt.Tx) error { 128 | var checks prchecklist.Checks 129 | 130 | checksBucket := tx.Bucket([]byte(boltBucketNameChecks)) 131 | 132 | dbKey := []byte(clRef.String()) 133 | data := checksBucket.Get(dbKey) 134 | if data != nil { 135 | err := json.Unmarshal(data, &checks) 136 | if err != nil { 137 | return err 138 | } 139 | } 140 | 141 | if checks == nil { 142 | checks = prchecklist.Checks{} 143 | } 144 | 145 | if checks.Add(key, user) == false { 146 | return nil 147 | } 148 | 149 | data, err := json.Marshal(&checks) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | return checksBucket.Put(dbKey, data) 155 | }) 156 | } 157 | 158 | // RemoveCheck implements coreRepository.RemoveCheck. 159 | func (r boltCoreRepository) RemoveCheck(ctx context.Context, clRef prchecklist.ChecklistRef, key string, user prchecklist.GitHubUser) error { 160 | if err := clRef.Validate(); err != nil { 161 | return err 162 | } 163 | 164 | return r.db.Update(func(tx *bolt.Tx) error { 165 | var checks prchecklist.Checks 166 | 167 | checksBucket := tx.Bucket([]byte(boltBucketNameChecks)) 168 | 169 | dbKey := []byte(clRef.String()) 170 | data := checksBucket.Get(dbKey) 171 | if data != nil { 172 | err := json.Unmarshal(data, &checks) 173 | if err != nil { 174 | return err 175 | } 176 | } 177 | 178 | if checks == nil { 179 | checks = prchecklist.Checks{} 180 | } 181 | 182 | if checks.Remove(key, user) == false { 183 | return nil 184 | } 185 | 186 | data, err := json.Marshal(&checks) 187 | if err != nil { 188 | return err 189 | } 190 | 191 | return checksBucket.Put(dbKey, data) 192 | }) 193 | } 194 | -------------------------------------------------------------------------------- /lib/repository/redis.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/url" 7 | "strconv" 8 | 9 | "github.com/garyburd/redigo/redis" 10 | "github.com/pkg/errors" 11 | 12 | "github.com/motemen/prchecklist/v2" 13 | ) 14 | 15 | const ( 16 | redisKeyPrefixUser = "user:" 17 | redisKeyPrefixCheck = "check:" 18 | ) 19 | 20 | type redisCoreRepository struct { 21 | url *url.URL 22 | } 23 | 24 | func init() { 25 | registerCoreRepositoryBuilder("redis", NewRedisCore) 26 | } 27 | 28 | // NewRedisCore creates a coreRepository backed by boltdb. 29 | // datasource must be a URL of form "redis://[:@]", 30 | // whose user is not used. 31 | func NewRedisCore(datasource string) (coreRepository, error) { 32 | u, err := url.Parse(datasource) 33 | if err != nil { 34 | return nil, err 35 | } 36 | return &redisCoreRepository{ 37 | url: u, 38 | }, nil 39 | } 40 | 41 | func (r redisCoreRepository) conn() (redis.Conn, error) { 42 | opts := []redis.DialOption{} 43 | if u := r.url.User; u != nil { 44 | if pw, ok := u.Password(); ok { 45 | opts = append(opts, redis.DialPassword(pw)) 46 | } 47 | } 48 | return redis.Dial("tcp", r.url.Host, opts...) 49 | } 50 | 51 | func (r redisCoreRepository) withConn(f func(redis.Conn) error) error { 52 | conn, err := r.conn() 53 | if err != nil { 54 | return err 55 | } 56 | 57 | defer conn.Close() 58 | 59 | return f(conn) 60 | } 61 | 62 | // AddUser implements coreRepository.AddUser. 63 | func (r redisCoreRepository) AddUser(ctx context.Context, user prchecklist.GitHubUser) error { 64 | err := r.withConn(func(conn redis.Conn) error { 65 | buf, err := json.Marshal(user) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | _, err = conn.Do("SET", redisKeyPrefixUser+strconv.FormatInt(int64(user.ID), 10), buf) 71 | return err 72 | }) 73 | return errors.Wrap(err, "AddUser") 74 | } 75 | 76 | // GetUsers implements coreRepository.GetUser. 77 | func (r redisCoreRepository) GetUsers(ctx context.Context, userIDs []int) (map[int]prchecklist.GitHubUser, error) { 78 | users := make(map[int]prchecklist.GitHubUser, len(userIDs)) 79 | if len(userIDs) == 0 { 80 | return users, nil 81 | } 82 | 83 | err := r.withConn(func(conn redis.Conn) error { 84 | keys := make([]interface{}, len(userIDs)) 85 | for i, id := range userIDs { 86 | keys[i] = redisKeyPrefixUser + strconv.FormatInt(int64(id), 10) 87 | } 88 | bufs, err := redis.ByteSlices(conn.Do("MGET", keys...)) 89 | if err != nil { 90 | return err 91 | } 92 | for i, buf := range bufs { 93 | var user prchecklist.GitHubUser 94 | if err := json.Unmarshal(buf, &user); err != nil { 95 | return err 96 | } 97 | users[userIDs[i]] = user 98 | } 99 | 100 | return nil 101 | }) 102 | 103 | return users, errors.Wrap(err, "GetUsers") 104 | } 105 | 106 | // GetChecks implements coreRepository.GetChecks. 107 | func (r redisCoreRepository) GetChecks(ctx context.Context, clRef prchecklist.ChecklistRef) (prchecklist.Checks, error) { 108 | if err := clRef.Validate(); err != nil { 109 | return nil, err 110 | } 111 | 112 | var checks prchecklist.Checks 113 | 114 | err := r.withConn(func(conn redis.Conn) error { 115 | key := redisKeyPrefixCheck + clRef.String() 116 | buf, err := redis.Bytes(conn.Do("GET", key)) 117 | if err == redis.ErrNil { 118 | return nil 119 | } else if err != nil { 120 | return err 121 | } 122 | 123 | return json.Unmarshal(buf, &checks) 124 | }) 125 | 126 | return checks, errors.Wrap(err, "GetChecks") 127 | } 128 | 129 | // AddCheck implements coreRepository.AddCheck. 130 | func (r redisCoreRepository) AddCheck(ctx context.Context, clRef prchecklist.ChecklistRef, key string, user prchecklist.GitHubUser) error { 131 | if err := clRef.Validate(); err != nil { 132 | return err 133 | } 134 | 135 | return r.withConn(func(conn redis.Conn) error { 136 | var checks prchecklist.Checks 137 | 138 | dbKey := redisKeyPrefixCheck + clRef.String() 139 | buf, err := redis.Bytes(conn.Do("GET", dbKey)) 140 | if err == redis.ErrNil { 141 | checks = prchecklist.Checks{} 142 | } else if err != nil { 143 | return err 144 | } else { 145 | err := json.Unmarshal(buf, &checks) 146 | if err != nil { 147 | return err 148 | } 149 | } 150 | 151 | if checks.Add(key, user) == false { 152 | return nil 153 | } 154 | 155 | data, err := json.Marshal(&checks) 156 | if err != nil { 157 | return err 158 | } 159 | 160 | _, err = conn.Do("SET", dbKey, data) 161 | return err 162 | }) 163 | } 164 | 165 | // RemoveCheck implements coreRepository.RemoveCheck. 166 | func (r redisCoreRepository) RemoveCheck(ctx context.Context, clRef prchecklist.ChecklistRef, key string, user prchecklist.GitHubUser) error { 167 | if err := clRef.Validate(); err != nil { 168 | return err 169 | } 170 | 171 | return r.withConn(func(conn redis.Conn) error { 172 | var checks prchecklist.Checks 173 | 174 | dbKey := redisKeyPrefixCheck + clRef.String() 175 | buf, err := redis.Bytes(conn.Do("GET", dbKey)) 176 | if err == redis.ErrNil { 177 | checks = prchecklist.Checks{} 178 | } else if err != nil { 179 | return err 180 | } else { 181 | err := json.Unmarshal(buf, &checks) 182 | if err != nil { 183 | return err 184 | } 185 | } 186 | 187 | if checks.Remove(key, user) == false { 188 | return nil 189 | } 190 | 191 | data, err := json.Marshal(&checks) 192 | if err != nil { 193 | return err 194 | } 195 | 196 | _, err = conn.Do("SET", dbKey, data) 197 | return err 198 | }) 199 | } 200 | -------------------------------------------------------------------------------- /static/typescript/__mocks__/api.ts: -------------------------------------------------------------------------------- 1 | import { ChecklistRef, ChecklistResponse, ErrorType } from "../api-schema"; 2 | 3 | export class APIError { 4 | constructor(public errorType: ErrorType) {} 5 | } 6 | 7 | export function getChecklist( 8 | ref: ChecklistRef 9 | ): Promise { 10 | return Promise.resolve({ 11 | Checklist: { 12 | URL: "https://github.com/motemen/test-repository/pull/2", 13 | Title: "Release 2017-10-11 20:18:22 +0900", 14 | Body: 15 | "Blah blah blah\n- [ ] #1 feature-1 @motemen\n- [ ] #3 foo bar baz foo foo foo foo foo foo foof foohof ofhfof @motemen\n- [ ] #33 mk-feature @motemen\n- [ ] #4 1403357307 @motemen\n- [ ] #7 feature-y @motemen", 16 | Owner: "motemen", 17 | Repo: "test-repository", 18 | Number: 2, 19 | IsPrivate: false, 20 | User: { Login: "motemen" }, 21 | Commits: [ 22 | { 23 | Message: "feature-1", 24 | Oid: "142d5962881d3db66bdd2c257486a72f2cb175d8", 25 | }, 26 | { 27 | Message: "Merge pull request #1 from motemen/feature-1\n\nfeature-1", 28 | Oid: "e966324ceb00fcdba463f9db10a2c95b362d5bbe", 29 | }, 30 | { 31 | Message: "a commit in feature-1403357222", 32 | Oid: "341df5410f1b2be3762b6c23cf9419c08830fb23", 33 | }, 34 | { 35 | Message: 36 | "Merge pull request #3 from motemen/feature-1403357222\n\nmerge pr", 37 | Oid: "ccaa7eb46e3bfeedeaa584f0b5081e3fb19ccdd9", 38 | }, 39 | { 40 | Message: "a commit in feature-1403357307", 41 | Oid: "79c1bc383667b5687c2b773d35a508db5f1954a4", 42 | }, 43 | { 44 | Message: 45 | "Merge pull request #4 from motemen/feature-1403357307\n\nmerge pr", 46 | Oid: "25b53e8fa82295e0fc358e43129c556318aa95b9", 47 | }, 48 | { 49 | Message: "feature-y", 50 | Oid: "cdf38b20b6d08a549c21f21b19c4c03a1da29dd4", 51 | }, 52 | { 53 | Message: "Merge pull request #7 from motemen/feature-y\n\nfeature-y", 54 | Oid: "63eb831808a209009fb0b3182e2c530f6d384ca3", 55 | }, 56 | { 57 | Message: "mk-feature", 58 | Oid: "bb37709ad41226eca2f5390b7ea041480077ef7b", 59 | }, 60 | { 61 | Message: 62 | "Merge pull request #33 from motemen/mk-feature\n\nmk-feature", 63 | Oid: "76a91cb8ff26a902e0da5aff34bdbcb8c4e58d4c", 64 | }, 65 | { 66 | Message: "+prchecklist.yml", 67 | Oid: "64b128586823f958c948e10eb88eae129b56ea68", 68 | }, 69 | ], 70 | ConfigBlobID: "b85e23e129e68bcf5677dd17860fa90d654a95d8", 71 | Stage: "qa", 72 | Items: [ 73 | { 74 | URL: "https://github.com/motemen/test-repository/pull/1", 75 | Title: "feature-1", 76 | Body: "", 77 | Owner: "motemen", 78 | Repo: "test-repository", 79 | Number: 1, 80 | IsPrivate: false, 81 | User: { Login: "motemen" }, 82 | Commits: [], 83 | ConfigBlobID: "", 84 | CheckedBy: [ 85 | { 86 | ID: 8465, 87 | Login: "motemen", 88 | AvatarURL: "https://avatars2.githubusercontent.com/u/8465?v=4", 89 | }, 90 | ], 91 | }, 92 | { 93 | URL: "https://github.com/motemen/test-repository/pull/3", 94 | Title: "foo bar baz foo foo foo foo foo foo foof foohof ofhfof", 95 | Body: "", 96 | Owner: "motemen", 97 | Repo: "test-repository", 98 | Number: 3, 99 | IsPrivate: false, 100 | User: { Login: "motemen" }, 101 | Commits: [], 102 | ConfigBlobID: "", 103 | CheckedBy: [], 104 | }, 105 | { 106 | URL: "https://github.com/motemen/test-repository/pull/4", 107 | Title: "1403357307", 108 | Body: "", 109 | Owner: "motemen", 110 | Repo: "test-repository", 111 | Number: 4, 112 | IsPrivate: false, 113 | User: { Login: "motemen" }, 114 | Commits: [], 115 | ConfigBlobID: "", 116 | CheckedBy: [ 117 | { 118 | ID: 8465, 119 | Login: "motemen", 120 | AvatarURL: "https://avatars2.githubusercontent.com/u/8465?v=4", 121 | }, 122 | ], 123 | }, 124 | { 125 | URL: "https://github.com/motemen/test-repository/pull/7", 126 | Title: "feature-y", 127 | Body: "", 128 | Owner: "motemen", 129 | Repo: "test-repository", 130 | Number: 7, 131 | IsPrivate: false, 132 | User: { Login: "werckerbot" }, 133 | Commits: [], 134 | ConfigBlobID: "", 135 | CheckedBy: [], 136 | }, 137 | { 138 | URL: "https://github.com/motemen/test-repository/pull/33", 139 | Title: "mk-feature", 140 | Body: "", 141 | Owner: "motemen", 142 | Repo: "test-repository", 143 | Number: 33, 144 | IsPrivate: false, 145 | User: { Login: "motemen" }, 146 | Commits: [], 147 | ConfigBlobID: "", 148 | CheckedBy: [], 149 | }, 150 | ], 151 | Config: { 152 | Stages: ["qa", "production"], 153 | Notification: { 154 | Events: { 155 | OnComplete: ["default"], 156 | OnCompleteChecksOfUser: [], 157 | OnCheck: ["default"], 158 | OnRemove: ["default"], 159 | }, 160 | Channels: null, 161 | }, 162 | }, 163 | }, 164 | Me: { 165 | ID: 8465, 166 | Login: "motemen", 167 | AvatarURL: "https://avatars2.githubusercontent.com/u/8465?v=4", 168 | }, 169 | }); 170 | } 171 | -------------------------------------------------------------------------------- /models.go: -------------------------------------------------------------------------------- 1 | package prchecklist 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/pkg/errors" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | // ChecklistResponse represents the JSON for a single Checklist. 13 | type ChecklistResponse struct { 14 | Checklist *Checklist 15 | Me *GitHubUser 16 | } 17 | 18 | // MeResponse represents the JSON for the top page. 19 | type MeResponse struct { 20 | Me *GitHubUser 21 | PullRequests map[string][]*PullRequest 22 | } 23 | 24 | // Checklist is the main entity of prchecklist. 25 | // It is identified by a "release" pull request PullRequest 26 | // (which is identified by its Owner, Repo and Number) and a Stage, if any. 27 | // The checklist Items correspond to "feature" pull requests 28 | // that have been merged into the head of "release" pull request 29 | // and the "release" pull request is about to merge into master. 30 | type Checklist struct { 31 | *PullRequest 32 | Stage string 33 | Items []*ChecklistItem 34 | Config *ChecklistConfig 35 | } 36 | 37 | // Completed returns whether all the items are checked by any user. 38 | func (c Checklist) Completed() bool { 39 | for _, item := range c.Items { 40 | if len(item.CheckedBy) == 0 { 41 | return false 42 | } 43 | } 44 | return true 45 | } 46 | 47 | // CompletedChecksOfUser returns whether all the items of user are checked by any user. 48 | func (c Checklist) CompletedChecksOfUser(user GitHubUserSimple) bool { 49 | for _, item := range c.Items { 50 | if len(item.CheckedBy) == 0 && item.User.Login == user.Login { 51 | return false 52 | } 53 | } 54 | return true 55 | } 56 | 57 | // Item returns the ChecklistItem associated by the feature PR number featNum. 58 | func (c Checklist) Item(featNum int) *ChecklistItem { 59 | for _, item := range c.Items { 60 | if item.Number == featNum { 61 | return item 62 | } 63 | } 64 | return nil 65 | } 66 | 67 | // Path returns the path used for the permalink of the checklist c. 68 | func (c Checklist) Path() string { 69 | path := fmt.Sprintf("/%s/%s/pull/%d", c.Owner, c.Repo, c.Number) 70 | if c.Stage != "" { 71 | path = path + "/" + c.Stage 72 | } 73 | return path 74 | } 75 | 76 | func (c Checklist) String() string { 77 | s := fmt.Sprintf("%s/%s#%d", c.Owner, c.Repo, c.Number) 78 | if c.Stage != "default" { 79 | s = s + "::" + c.Stage 80 | } 81 | return s 82 | } 83 | 84 | // ChecklistConfig is a configuration object for the repository, 85 | // which is specified by prchecklist.yml on the top of the repository. 86 | type ChecklistConfig struct { 87 | Stages []string 88 | Notification struct { 89 | Events struct { 90 | OnComplete []string `yaml:"on_complete"` // channel names 91 | OnCompleteChecksOfUser []string `yaml:"on_complete_checks_of_user"` // channel names 92 | OnCheck []string `yaml:"on_check"` // channel names 93 | OnRemove []string `yaml:"on_remove"` // channel names 94 | } 95 | Channels map[string]struct{ URL string } 96 | } 97 | } 98 | 99 | // ChecklistItem is a checklist item, which belongs to a Checklist 100 | // and can be checked by multiple GitHubUsers. 101 | type ChecklistItem struct { 102 | // the "feature" pull request corresponds to this item 103 | *PullRequest 104 | CheckedBy []GitHubUser 105 | } 106 | 107 | // Checks is a value object obtained by repository.Repositor.GetChecks, 108 | // which is a map from string key to IDs of GitHubUsers. 109 | // It is ready for serialization/deserialization. 110 | // For future extension, use strings instead of ints 111 | // for the keys of Checks. 112 | type Checks map[string][]int // "PullReqNumber" -> []UserID 113 | 114 | // ChecksKeyFeatureNum builds key string to use for Checks 115 | // from a feature pull request number featNum. 116 | func ChecksKeyFeatureNum(featNum int) string { 117 | return fmt.Sprint(featNum) 118 | } 119 | 120 | // Add adds a check for featNum by user. 121 | func (c Checks) Add(featNum string, user GitHubUser) bool { 122 | for _, userID := range c[featNum] { 123 | if user.ID == userID { 124 | // already checked 125 | return false 126 | } 127 | } 128 | 129 | c[featNum] = append(c[featNum], user.ID) 130 | return true 131 | } 132 | 133 | // Remove removes a check for featNum by user. 134 | func (c Checks) Remove(featNum string, user GitHubUser) bool { 135 | for i, userID := range c[featNum] { 136 | if user.ID == userID { 137 | c[featNum] = append(c[featNum][0:i], c[featNum][i+1:]...) 138 | return true 139 | } 140 | } 141 | 142 | return false 143 | } 144 | 145 | // ChecklistRef represents a pointer to Checklist. 146 | type ChecklistRef struct { 147 | Owner string 148 | Repo string 149 | Number int 150 | Stage string 151 | } 152 | 153 | func (clRef ChecklistRef) String() string { 154 | return fmt.Sprintf("%s/%s#%d::%s", clRef.Owner, clRef.Repo, clRef.Number, clRef.Stage) 155 | } 156 | 157 | // Validate validates is clRef is valid or returns error. 158 | func (clRef ChecklistRef) Validate() error { 159 | if clRef.Number == 0 || clRef.Stage == "" { 160 | return errors.Errorf("not a valid checklist reference: %q", clRef) 161 | } 162 | 163 | return nil 164 | } 165 | 166 | // PullRequest represens a pull request on GitHub. 167 | type PullRequest struct { 168 | URL string 169 | Title string 170 | Body string 171 | Owner string 172 | Repo string 173 | Number int 174 | IsPrivate bool 175 | User GitHubUserSimple 176 | 177 | // Filled for "base" pull reqs 178 | Commits []Commit 179 | ConfigBlobID string 180 | } 181 | 182 | // Commit is a commit data on GitHub. 183 | type Commit struct { 184 | Message string 185 | Oid string 186 | } 187 | 188 | // GitHubUserSimple is a minimalistic GitHub user data. 189 | type GitHubUserSimple struct { 190 | Login string 191 | } 192 | 193 | // GitHubUser is represents a GitHub user. 194 | // Its Token field is populated only for the representation of 195 | // a visiting client. 196 | type GitHubUser struct { 197 | ID int 198 | Login string 199 | AvatarURL string 200 | Token *oauth2.Token `json:"-"` 201 | } 202 | 203 | // HTTPClient creates an *http.Client which uses u.Token 204 | // to be used for GitHub API client on behalf of the user u. 205 | func (u GitHubUser) HTTPClient(ctx context.Context) *http.Client { 206 | return oauth2.NewClient(ctx, oauth2.StaticTokenSource(u.Token)) 207 | } 208 | 209 | // ErrorResponse corresponds to JSON containing error results in APIs. 210 | type ErrorResponse struct { 211 | Type ErrorType 212 | } 213 | 214 | // ErrorType indicates the type of ErrorResponse. 215 | type ErrorType string 216 | 217 | const ( 218 | // ErrorTypeNotAuthed means: Visitor has not been authenticated. Should visit /auth 219 | ErrorTypeNotAuthed ErrorType = "not_authed" 220 | ) 221 | -------------------------------------------------------------------------------- /static/typescript/__snapshots__/ChecklistComponent.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` 1`] = ` 4 |
5 | Loading... 6 |
7 | `; 8 | 9 | exports[` 2`] = ` 10 |
13 | 17 | 54 |

55 | 58 | 61 | # 62 | 2 63 | 64 | 65 | Release 2017-10-11 20:18:22 +0900 66 | 67 |

68 |
72 |
    73 |
  • 74 |
    77 | 83 |
    84 | 94 | 95 |
    99 | feature-1 100 |
    101 | 102 |
    105 | @ 106 | motemen 107 |
    108 | 109 |
    112 | 115 | motemen 119 | 120 |
    121 |
  • 122 |
  • 123 |
    126 | 132 |
    133 | 143 | 144 |
    148 | foo bar baz foo foo foo foo foo foo foof foohof ofhfof 149 |
    150 | 151 |
    154 | @ 155 | motemen 156 |
    157 | 158 |
    161 |
  • 162 |
  • 163 |
    166 | 172 |
    173 | 183 | 184 |
    188 | 1403357307 189 |
    190 | 191 |
    194 | @ 195 | motemen 196 |
    197 | 198 |
    201 | 204 | motemen 208 | 209 |
    210 |
  • 211 |
  • 212 |
    215 | 221 |
    222 | 232 | 233 |
    237 | feature-y 238 |
    239 | 240 |
    243 | @ 244 | werckerbot 245 |
    246 | 247 |
    250 |
  • 251 |
  • 252 |
    255 | 261 |
    262 | 272 | 273 |
    277 | mk-feature 278 |
    279 | 280 |
    283 | @ 284 | motemen 285 |
    286 | 287 |
    290 |
  • 291 |
292 |
293 |
294 |     Blah blah blah
295 | - [ ] #1 feature-1 @motemen
296 | - [ ] #3 foo bar baz foo foo foo foo foo foo foof foohof ofhfof @motemen
297 | - [ ] #33 mk-feature @motemen
298 | - [ ] #4 1403357307 @motemen
299 | - [ ] #7 feature-y @motemen
300 |   
301 |
302 | `; 303 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/nn/w0sql9795877_3l0qmfh__h40000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: undefined, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: undefined, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: undefined, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | preset: "ts-jest", 95 | 96 | // Run tests from one or more projects 97 | // projects: undefined, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: undefined, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: undefined, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | // setupFilesAfterEnv: [], 130 | 131 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 132 | // snapshotSerializers: [], 133 | 134 | // The test environment that will be used for testing 135 | // testEnvironment: "jest-environment-jsdom", 136 | 137 | // Options that will be passed to the testEnvironment 138 | // testEnvironmentOptions: {}, 139 | 140 | // Adds a location field to test results 141 | // testLocationInResults: false, 142 | 143 | // The glob patterns Jest uses to detect test files 144 | // testMatch: [ 145 | // "**/__tests__/**/*.[jt]s?(x)", 146 | // "**/?(*.)+(spec|test).[tj]s?(x)" 147 | // ], 148 | 149 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 150 | testPathIgnorePatterns: [ 151 | "/integration/", 152 | "/node_modules/" 153 | ], 154 | 155 | // The regexp pattern or array of patterns that Jest uses to detect test files 156 | // testRegex: [], 157 | 158 | // This option allows the use of a custom results processor 159 | // testResultsProcessor: undefined, 160 | 161 | // This option allows use of a custom test runner 162 | // testRunner: "jasmine2", 163 | 164 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 165 | // testURL: "http://localhost", 166 | 167 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 168 | // timers: "real", 169 | 170 | // A map from regular expressions to paths to transformers 171 | // transform: undefined, 172 | 173 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 174 | // transformIgnorePatterns: [ 175 | // "/node_modules/" 176 | // ], 177 | 178 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 179 | // unmockedModulePathPatterns: undefined, 180 | 181 | // Indicates whether each individual test should be reported during the run 182 | // verbose: undefined, 183 | 184 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 185 | // watchPathIgnorePatterns: [], 186 | 187 | // Whether to use watchman for file crawling 188 | // watchman: true, 189 | }; 190 | -------------------------------------------------------------------------------- /static/typescript/ChecklistComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as API from "./api"; 3 | import { NavComponent } from "./NavComponent"; 4 | 5 | interface ChecklistProps { 6 | checklistRef: API.ChecklistRef; 7 | } 8 | 9 | interface ChecklistState { 10 | checklist?: API.Checklist; 11 | me?: API.GitHubUser; 12 | loading: boolean; 13 | error?: any; 14 | } 15 | 16 | export class ChecklistComponent extends React.Component< 17 | ChecklistProps, 18 | ChecklistState 19 | > { 20 | constructor(props: ChecklistProps) { 21 | super(props); 22 | 23 | this.state = { loading: false }; 24 | 25 | API.getChecklist(props.checklistRef) 26 | .then((data) => { 27 | if (data instanceof API.APIError) { 28 | if (data.errorType === "not_authed") { 29 | location.href = `/auth?return_to=${encodeURIComponent( 30 | location.pathname 31 | )}`; 32 | return; 33 | } 34 | throw data; 35 | } 36 | if (data.Checklist) { 37 | if (this.ensureCorrectStage(data.Checklist)) { 38 | return; 39 | } 40 | } 41 | this.setState({ 42 | checklist: data.Checklist, 43 | me: data.Me, 44 | }); 45 | }) 46 | .catch((err) => { 47 | this.setState({ error: `${err}` }); 48 | console.error(err); 49 | }); 50 | } 51 | 52 | public render() { 53 | if (this.state.error) { 54 | return ( 55 |
56 | 57 |
{this.state.error}
58 |
59 | ); 60 | } 61 | 62 | const checklist = this.state.checklist; 63 | if (!checklist) { 64 | return
Loading...
; 65 | } 66 | 67 | const stages = this.checklistStages(); 68 | 69 | return ( 70 |
71 | 75 | 78 | {checklist.Owner}/{checklist.Repo}#{checklist.Number} 79 | {checklist.IsPrivate ? 🔒 : ""} 80 | 81 | } 82 | stages={ 83 | stages.length ? ( 84 | 93 | ) : null 94 | } 95 | me={this.state.me} 96 | /> 97 |

98 | 99 | #{checklist.Number} {checklist.Title} 100 | 101 |

102 |
103 |
    104 | {checklist.Items.map((item) => { 105 | return ( 106 |
  • 107 |
    108 | 116 |
    117 |
    118 | #{item.Number} 119 |
    {" "} 120 |
    121 | {item.Title} 122 |
    {" "} 123 |
    @{item.User.Login}
    {" "} 124 |
    125 | {item.CheckedBy.map((user) => { 126 | return ( 127 | 131 | {user.Login} 132 | 133 | ); 134 | })} 135 |
    136 |
  • 137 | ); 138 | })} 139 |
140 |
141 |
{checklist.Body}
142 |
143 | ); 144 | } 145 | 146 | private ensureCorrectStage(checklist: API.Checklist): boolean { 147 | const stages = (checklist.Config && checklist.Config.Stages) || []; 148 | const checklistRef = this.props.checklistRef; 149 | if (stages.length) { 150 | if (stages.findIndex((s) => s === checklistRef.Stage) === -1) { 151 | this.navigateToStage(stages[0]); 152 | return true; 153 | } 154 | } else { 155 | if (checklistRef.Stage !== "") { 156 | this.navigateToStage(""); 157 | return true; 158 | } 159 | } 160 | 161 | return false; 162 | } 163 | 164 | private navigateToStage(stage: string) { 165 | const checklistRef = this.props.checklistRef; 166 | if (stage === "") { 167 | location.replace( 168 | `/${checklistRef.Owner}/${checklistRef.Repo}/pull/${checklistRef.Number}` 169 | ); 170 | } else { 171 | location.replace( 172 | `/${checklistRef.Owner}/${checklistRef.Repo}/pull/${checklistRef.Number}/${stage}` 173 | ); 174 | } 175 | } 176 | 177 | private handleOnClickChecklistItem = ( 178 | item: API.ChecklistItem 179 | ): React.MouseEventHandler => { 180 | return (ev: React.MouseEvent) => { 181 | const checked = !this.itemIsCheckedByMe(item); 182 | 183 | this.setState((prevState: ChecklistState, props) => { 184 | prevState.checklist.Items.forEach((it) => { 185 | if (it.Number === item.Number) { 186 | console.log(it); 187 | if (checked) { 188 | it.CheckedBy = it.CheckedBy.concat(this.state.me); 189 | } else { 190 | it.CheckedBy = it.CheckedBy.filter( 191 | (user) => user.ID !== this.state.me.ID 192 | ); 193 | } 194 | } 195 | }); 196 | return { ...prevState, loading: true }; 197 | }); 198 | 199 | API.setCheck(this.props.checklistRef, item.Number, checked).then( 200 | (data) => { 201 | this.setState({ 202 | checklist: data.Checklist, 203 | loading: false, 204 | me: data.Me, 205 | }); 206 | } 207 | ); 208 | }; 209 | }; 210 | 211 | private handleOnSelectStage = (ev: React.ChangeEvent) => { 212 | this.navigateToStage(ev.target.value); 213 | }; 214 | 215 | private itemIsCheckedByMe(item: API.ChecklistItem): boolean { 216 | return ( 217 | item.CheckedBy.findIndex((user) => user.ID === this.state.me.ID) !== -1 218 | ); 219 | } 220 | 221 | private checklistStages(): string[] { 222 | if (this.state.checklist && this.state.checklist.Config) { 223 | return this.state.checklist.Config.Stages || []; 224 | } 225 | 226 | return []; 227 | } 228 | 229 | private completed(): boolean { 230 | const checklist = this.state.checklist; 231 | if (!checklist) return false; 232 | 233 | return checklist.Items.every((item) => item.CheckedBy.length > 0); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [v2.7.0](https://github.com/motemen/prchecklist/compare/v2.6.0...v2.7.0) (2020-06-23) 2 | 3 | * Fix 4 | * Fix user of `on_complete_checks_of_user` event [#139](https://github.com/motemen/prchecklist/pull/139) ([mechairoi](https://github.com/mechairoi)) 5 | * Add renovate label to issues/pull requests that from Renovate [#138](https://github.com/motemen/prchecklist/pull/138) ([aereal](https://github.com/aereal)) 6 | * Test 7 | * test: Add tests usecase gateway [#134](https://github.com/motemen/prchecklist/pull/134) ([motemen](https://github.com/motemen)) 8 | * use puppeteer for testing [#132](https://github.com/motemen/prchecklist/pull/132) ([motemen](https://github.com/motemen)) 9 | * use env [#123](https://github.com/motemen/prchecklist/pull/123) ([motemen](https://github.com/motemen)) 10 | * add tests [#122](https://github.com/motemen/prchecklist/pull/122) ([motemen](https://github.com/motemen)) 11 | * Update dependencies 12 | * chore(deps): update devdependencies [#136](https://github.com/motemen/prchecklist/pull/136) ([renovate[bot]](https://github.com/apps/renovate)) 13 | * chore(deps): update definitelytyped [#129](https://github.com/motemen/prchecklist/pull/129) ([renovate[bot]](https://github.com/apps/renovate)) 14 | * chore(deps): update module yaml to v2.3.0 [#130](https://github.com/motemen/prchecklist/pull/130) ([renovate[bot]](https://github.com/apps/renovate)) 15 | * fix(deps): update dependency uikit to v3.4.6 [#124](https://github.com/motemen/prchecklist/pull/124) ([renovate[bot]](https://github.com/apps/renovate)) 16 | * chore(deps): update devdependencies [#125](https://github.com/motemen/prchecklist/pull/125) ([renovate[bot]](https://github.com/apps/renovate)) 17 | * chore(deps): update module cloud.google.com/go to v0.57.0 [#126](https://github.com/motemen/prchecklist/pull/126) ([renovate[bot]](https://github.com/apps/renovate)) 18 | * Update dependency json-schema-to-typescript to v9 [#121](https://github.com/motemen/prchecklist/pull/121) ([renovate[bot]](https://github.com/apps/renovate)) 19 | * Update devDependencies [#120](https://github.com/motemen/prchecklist/pull/120) ([renovate[bot]](https://github.com/apps/renovate)) 20 | * chore(deps): pin dependencies [#135](https://github.com/motemen/prchecklist/pull/135) ([renovate[bot]](https://github.com/apps/renovate)) 21 | * use go-github/v31 [#133](https://github.com/motemen/prchecklist/pull/133) ([motemen](https://github.com/motemen)) 22 | 23 | ## [v2.6.0](https://github.com/motemen/prchecklist/compare/v2.5.0...v2.6.0) (2020-04-28) 24 | 25 | * check host [#116](https://github.com/motemen/prchecklist/pull/116) ([motemen](https://github.com/motemen)) 26 | * Update devDependencies [#109](https://github.com/motemen/prchecklist/pull/109) ([renovate[bot]](https://github.com/apps/renovate)) 27 | * Update dependency ts-loader to v7 [#110](https://github.com/motemen/prchecklist/pull/110) ([renovate[bot]](https://github.com/apps/renovate)) 28 | * codecov [#115](https://github.com/motemen/prchecklist/pull/115) ([motemen](https://github.com/motemen)) 29 | * Heroku build assets [#114](https://github.com/motemen/prchecklist/pull/114) ([motemen](https://github.com/motemen)) 30 | * Fix version [#112](https://github.com/motemen/prchecklist/pull/112) ([motemen](https://github.com/motemen)) 31 | 32 | ## [v2.5.0](https://github.com/motemen/prchecklist/compare/v2.4.2...v2.5.0) (2020-04-25) 33 | 34 | * OAuth support for review apps 35 | * Oauth forward [#108](https://github.com/motemen/prchecklist/pull/108) ([motemen](https://github.com/motemen)) 36 | * fix review app sigs [#90](https://github.com/motemen/prchecklist/pull/90) ([motemen](https://github.com/motemen)) 37 | * support for review apps [#86](https://github.com/motemen/prchecklist/pull/86) ([motemen](https://github.com/motemen)) 38 | * Module updates with Renovate 39 | * [#87](https://github.com/motemen/prchecklist/pull/87), [#99](https://github.com/motemen/prchecklist/pull/99), [#100](https://github.com/motemen/prchecklist/pull/100), [#106](https://github.com/motemen/prchecklist/pull/106), [#104](https://github.com/motemen/prchecklist/pull/104), [#105](https://github.com/motemen/prchecklist/pull/105), [#103](https://github.com/motemen/prchecklist/pull/103), [#93](https://github.com/motemen/prchecklist/pull/93), [#94](https://github.com/motemen/prchecklist/pull/94), [#80](https://github.com/motemen/prchecklist/pull/80), [#76](https://github.com/motemen/prchecklist/pull/76), [#85](https://github.com/motemen/prchecklist/pull/85), [#84](https://github.com/motemen/prchecklist/pull/84), [#89](https://github.com/motemen/prchecklist/pull/89), [#91](https://github.com/motemen/prchecklist/pull/91), [#82](https://github.com/motemen/prchecklist/pull/82), [#81](https://github.com/motemen/prchecklist/pull/81), [#75](https://github.com/motemen/prchecklist/pull/75), [#92](https://github.com/motemen/prchecklist/pull/92), [#79](https://github.com/motemen/prchecklist/pull/79), [#78](https://github.com/motemen/prchecklist/pull/78), [#74](https://github.com/motemen/prchecklist/pull/74), [#73](https://github.com/motemen/prchecklist/pull/73), [#72](https://github.com/motemen/prchecklist/pull/72) 40 | 41 | ## [v2.4.2](https://github.com/motemen/prchecklist/compare/v2.4.1...v2.4.2) (2020-04-07) 42 | 43 | * use environment variables as a source of the version instead of a meta element [#71](https://github.com/motemen/prchecklist/pull/71) ([aereal](https://github.com/aereal)) 44 | * Test TypeScript [#70](https://github.com/motemen/prchecklist/pull/70) ([motemen](https://github.com/motemen)) 45 | 46 | ## [v2.4.1](https://github.com/motemen/prchecklist/compare/v2.4.0...v2.4.1) (2020-04-03) 47 | 48 | * Upgrade npm modules [#69](https://github.com/motemen/prchecklist/pull/69) ([motemen](https://github.com/motemen)) 49 | 50 | ## [v2.4.0](https://github.com/motemen/prchecklist/compare/v2.3.0...v2.4.0) (2020-03-24) 51 | 52 | * Support Docker [#64](https://github.com/motemen/prchecklist/pull/64) 53 | * Support Google App Engine [#65](https://github.com/motemen/prchecklist/pull/65) 54 | * Modified module path to align with Go modules versioning [#66](https://github.com/motemen/prchecklist/pull/66) 55 | 56 | ## [v2.3.0](https://github.com/motemen/prchecklist/compare/v2.2.2...v2.3.0) (2019-08-22) 57 | 58 | * Add complete notification for each user [#63](https://github.com/motemen/prchecklist/pull/63) ([mechairoi](https://github.com/mechairoi)) 59 | * Migrate to Go Modules・Update libraries [#62](https://github.com/motemen/prchecklist/pull/62) ([itchyny](https://github.com/itchyny)) 60 | * Fix CI [#61](https://github.com/motemen/prchecklist/pull/61) ([mechairoi](https://github.com/mechairoi)) 61 | * Refactor: organize status update [#59](https://github.com/motemen/prchecklist/pull/59) ([aereal](https://github.com/aereal)) 62 | 63 | ## [v2.2.2](https://github.com/motemen/prchecklist/compare/v2.2.1...v2.2.2) (2018-06-06) 64 | 65 | * include checklist URL in GitHub status [#57](https://github.com/motemen/prchecklist/pull/57) ([motemen](https://github.com/motemen)) 66 | 67 | ### v2.2.1 68 | 69 | * Enhance commit status [#56](https://github.com/motemen/prchecklist/pull/56) ([aereal](https://github.com/aereal)) 70 | 71 | ### v2.2.0 72 | 73 | * Set checklist completed status using status API [#55](https://github.com/motemen/prchecklist/pull/55) ([aereal](https://github.com/aereal)) 74 | * refactor Makefile [#51](https://github.com/motemen/prchecklist/pull/51) ([motemen](https://github.com/motemen)) 75 | 76 | ## [v2.1.0](https://github.com/motemen/prchecklist/compare/v2.0.0...v2.1.0) (2017-10-21) 77 | 78 | * [Feature] Better experience around session [#50](https://github.com/motemen/prchecklist/pull/50) ([motemen](https://github.com/motemen)) 79 | * [Feature] auto-redirect to /auth when receiving not_authed error [#49](https://github.com/motemen/prchecklist/pull/49) ([motemen](https://github.com/motemen)) 80 | * refactor Makefile [#51](https://github.com/motemen/prchecklist/pull/51) ([motemen](https://github.com/motemen)) 81 | * Lint [#48](https://github.com/motemen/prchecklist/pull/48) ([motemen](https://github.com/motemen)) 82 | * use JSON Schema to generate TypeScript API definitions [#47](https://github.com/motemen/prchecklist/pull/47) ([motemen](https://github.com/motemen)) 83 | -------------------------------------------------------------------------------- /lib/usecase/usecase.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "regexp" 8 | "strconv" 9 | 10 | "github.com/pkg/errors" 11 | "golang.org/x/sync/errgroup" 12 | "golang.org/x/tools/container/intsets" 13 | "gopkg.in/yaml.v2" 14 | 15 | "github.com/motemen/prchecklist/v2" 16 | ) 17 | 18 | // GitHubGateway is an interface that makes API calls to GitHub (Enterprise) and 19 | // retrieves information about repositories. 20 | // Implemented by gateway.GitHub. 21 | type GitHubGateway interface { 22 | GetBlob(ctx context.Context, ref prchecklist.ChecklistRef, sha string) ([]byte, error) 23 | GetPullRequest(ctx context.Context, clRef prchecklist.ChecklistRef, isMain bool) (*prchecklist.PullRequest, context.Context, error) 24 | GetRecentPullRequests(ctx context.Context) (map[string][]*prchecklist.PullRequest, error) 25 | SetRepositoryStatusAs(ctx context.Context, owner, repo, ref, contextName, state, targetURL string) error 26 | } 27 | 28 | // CoreRepository is a repository for prchecklist's core data, 29 | // namely Checks and GitHubUsers. 30 | type CoreRepository interface { 31 | // GetChecks returns the Checks for the checklist pointed by clRef 32 | GetChecks(ctx context.Context, clRef prchecklist.ChecklistRef) (prchecklist.Checks, error) 33 | // AddCheck updates the Checks for the checklist pointed by clRef, by adding a check of the user for the item specified by key. 34 | AddCheck(ctx context.Context, clRef prchecklist.ChecklistRef, key string, user prchecklist.GitHubUser) error 35 | // RemoveCheck updates the Checks for the checklist pointed by clRef, by removing a check of the user for the item specified by key. 36 | RemoveCheck(ctx context.Context, clRef prchecklist.ChecklistRef, key string, user prchecklist.GitHubUser) error 37 | 38 | // AddUser registers the user's data, which can retrieved by GetUsers. 39 | AddUser(ctx context.Context, user prchecklist.GitHubUser) error 40 | // GetUsers retrieves the users' data registered by AddUser. 41 | GetUsers(ctx context.Context, userIDs []int) (map[int]prchecklist.GitHubUser, error) 42 | } 43 | 44 | // Usecase stands for the use cases of this application by its methods. 45 | type Usecase struct { 46 | coreRepo CoreRepository 47 | github GitHubGateway 48 | } 49 | 50 | // New creates a new Usecase. 51 | func New(github GitHubGateway, coreRepo CoreRepository) *Usecase { 52 | return &Usecase{ 53 | coreRepo: coreRepo, 54 | github: github, 55 | } 56 | } 57 | 58 | // GetChecklist retrieves a Checklist pointed by clRef. 59 | // It makes some call to GitHub to create a complete view of one checklist. 60 | // Only this method can create prchecklist.Checklist. 61 | func (u Usecase) GetChecklist(ctx context.Context, clRef prchecklist.ChecklistRef) (*prchecklist.Checklist, error) { 62 | pr, ctx, err := u.github.GetPullRequest(ctx, clRef, true) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | refs := u.mergedPullRequestRefs(pr) 68 | 69 | checklist := &prchecklist.Checklist{ 70 | PullRequest: pr, 71 | Stage: clRef.Stage, 72 | Items: make([]*prchecklist.ChecklistItem, len(refs)), 73 | Config: nil, 74 | } 75 | 76 | { 77 | g, ctx := errgroup.WithContext(ctx) 78 | for i, ref := range refs { 79 | i, ref := i, ref 80 | g.Go(func() error { 81 | featurePullReq, _, err := u.github.GetPullRequest(ctx, ref, false) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | checklist.Items[i] = &prchecklist.ChecklistItem{ 87 | PullRequest: featurePullReq, 88 | CheckedBy: []prchecklist.GitHubUser{}, // filled up later 89 | } 90 | return nil 91 | }) 92 | } 93 | 94 | if pr.ConfigBlobID != "" { 95 | g.Go(func() error { 96 | buf, err := u.github.GetBlob(ctx, clRef, pr.ConfigBlobID) 97 | if err != nil { 98 | return errors.Wrap(err, "github.GetBlob") 99 | } 100 | 101 | checklist.Config, err = u.loadConfig(buf) 102 | return err 103 | }) 104 | } 105 | 106 | err = g.Wait() 107 | if err != nil { 108 | return nil, err 109 | } 110 | } 111 | 112 | // may move to before fetching feature pullreqs 113 | // for early return 114 | checks, err := u.coreRepo.GetChecks(ctx, clRef) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | log.Printf("%s: checks: %+v", clRef, checks) 120 | 121 | var s intsets.Sparse 122 | for _, userIDs := range checks { 123 | for _, id := range userIDs { 124 | s.Insert(id) 125 | } 126 | } 127 | 128 | users, err := u.coreRepo.GetUsers(ctx, s.AppendTo(nil)) 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | for _, item := range checklist.Items { 134 | for _, id := range checks[prchecklist.ChecksKeyFeatureNum(item.Number)] { 135 | item.CheckedBy = append(item.CheckedBy, users[id]) 136 | } 137 | } 138 | 139 | return checklist, nil 140 | } 141 | 142 | func (u Usecase) loadConfig(buf []byte) (*prchecklist.ChecklistConfig, error) { 143 | var config prchecklist.ChecklistConfig 144 | err := yaml.Unmarshal(buf, &config) 145 | if err != nil { 146 | return nil, errors.Wrap(err, "yaml.Unmarshal") 147 | } 148 | 149 | if config.Notification.Events.OnCheck == nil { 150 | config.Notification.Events.OnCheck = []string{"default"} 151 | } 152 | 153 | if config.Notification.Events.OnComplete == nil { 154 | config.Notification.Events.OnComplete = []string{"default"} 155 | } 156 | 157 | if config.Notification.Events.OnRemove == nil { 158 | config.Notification.Events.OnRemove = []string{"default"} 159 | } 160 | 161 | if config.Notification.Events.OnCompleteChecksOfUser == nil { 162 | config.Notification.Events.OnCompleteChecksOfUser = []string{} 163 | } 164 | 165 | return &config, nil 166 | } 167 | 168 | // AddUser calls a repo to register the information of a user. 169 | func (u Usecase) AddUser(ctx context.Context, user prchecklist.GitHubUser) error { 170 | return u.coreRepo.AddUser(ctx, user) 171 | } 172 | 173 | // AddCheck adds a check by the user for a checklist item for a feature pull reuquest number featNum, for the checklist pointed by clRef. 174 | // On checking, it may send notifications according to the configuration on prchecklist.yml. 175 | // NOTE: we may not need user, could receive only token (from ctx) for checking visiblities & gettting user info 176 | func (u Usecase) AddCheck(ctx context.Context, clRef prchecklist.ChecklistRef, featNum int, user prchecklist.GitHubUser) (*prchecklist.Checklist, error) { 177 | err := u.coreRepo.AddCheck(ctx, clRef, prchecklist.ChecksKeyFeatureNum(featNum), user) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | checklist, err := u.GetChecklist(ctx, clRef) 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | // TODO: check item existence? 188 | go func(ctx context.Context) { 189 | // notify in sequence 190 | events := []notificationEvent{ 191 | addCheckEvent{checklist: checklist, item: checklist.Item(featNum), user: user}, 192 | } 193 | if author := checklist.Item(featNum).User; checklist.CompletedChecksOfUser(author) { 194 | events = append(events, completeChecksOfUserEvent{checklist: checklist, user: author}) 195 | } 196 | if checklist.Completed() { 197 | events = append(events, completeEvent{checklist: checklist}) 198 | } 199 | for _, event := range events { 200 | err := u.notifyEvent(ctx, checklist, event) 201 | if err != nil { 202 | log.Printf("notifyEvent(%v): %s", event, err) 203 | } 204 | } 205 | }(prchecklist.NewContextWithValuesOf(ctx)) 206 | 207 | return checklist, nil 208 | } 209 | 210 | // RemoveCheck removes a check from a checklist pointed by clRef. 211 | func (u Usecase) RemoveCheck(ctx context.Context, clRef prchecklist.ChecklistRef, featNum int, user prchecklist.GitHubUser) (*prchecklist.Checklist, error) { 212 | // TODO: check featNum existence 213 | // NOTE: could receive only token (from ctx) and check visiblities & get user info 214 | err := u.coreRepo.RemoveCheck(ctx, clRef, prchecklist.ChecksKeyFeatureNum(featNum), user) 215 | if err != nil { 216 | return nil, err 217 | } 218 | 219 | cl, err := u.GetChecklist(ctx, clRef) 220 | if err != nil { 221 | return nil, err 222 | } 223 | 224 | go func(ctx context.Context, cl *prchecklist.Checklist) { 225 | events := []notificationEvent{ 226 | removeCheckEvent{ 227 | checklist: cl, 228 | item: cl.Item(featNum), 229 | user: user, 230 | }, 231 | } 232 | for _, event := range events { 233 | err := u.notifyEvent(ctx, cl, event) 234 | if err != nil { 235 | log.Printf("notifyEvent(%v): %s", event, err) 236 | } 237 | } 238 | }(prchecklist.NewContextWithValuesOf(ctx), cl) 239 | 240 | return cl, nil 241 | } 242 | 243 | var rxMergeCommitMessage = regexp.MustCompile(`\AMerge pull request #(?P\d+) `) 244 | 245 | func (u Usecase) mergedPullRequestRefs(pr *prchecklist.PullRequest) []prchecklist.ChecklistRef { 246 | refs := []prchecklist.ChecklistRef{} 247 | for _, commit := range pr.Commits { 248 | m := rxMergeCommitMessage.FindStringSubmatch(commit.Message) 249 | if m == nil { 250 | continue 251 | } 252 | n, _ := strconv.ParseInt(m[1], 10, 0) 253 | if n > 0 { 254 | refs = append(refs, prchecklist.ChecklistRef{ 255 | Owner: pr.Owner, 256 | Repo: pr.Repo, 257 | Number: int(n), 258 | }) 259 | } 260 | } 261 | return refs 262 | } 263 | 264 | // GetRecentPullRequests list recent pullrequests the user may be interested in. 265 | // Crafted for the top page. 266 | func (u Usecase) GetRecentPullRequests(ctx context.Context) (map[string][]*prchecklist.PullRequest, error) { 267 | return u.github.GetRecentPullRequests(ctx) 268 | } 269 | 270 | func (u Usecase) setRepositoryCompletedStatusAs(ctx context.Context, owner, repo, ref, state, stage, targetURL string) error { 271 | return u.github.SetRepositoryStatusAs(ctx, owner, repo, ref, fmt.Sprintf("prchecklist/%s/completed", stage), state, targetURL) 272 | } 273 | -------------------------------------------------------------------------------- /lib/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "encoding/gob" 8 | "encoding/json" 9 | "flag" 10 | "fmt" 11 | "log" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "strings" 16 | "time" 17 | 18 | "golang.org/x/oauth2" 19 | 20 | "github.com/elazarl/go-bindata-assetfs" 21 | "github.com/gorilla/handlers" 22 | "github.com/gorilla/mux" 23 | "github.com/gorilla/schema" 24 | "github.com/gorilla/sessions" 25 | "github.com/pkg/errors" 26 | 27 | "github.com/motemen/prchecklist/v2" 28 | "github.com/motemen/prchecklist/v2/lib/oauthforwarder" 29 | "github.com/motemen/prchecklist/v2/lib/usecase" 30 | ) 31 | 32 | var ( 33 | sessionSecret = os.Getenv("PRCHECKLIST_SESSION_SECRET") 34 | behindProxy = os.Getenv("PRCHECKLIST_BEHIND_PROXY") != "" 35 | ) 36 | 37 | const sessionName = "s" 38 | 39 | const ( 40 | sessionKeyOAuthState = "oauthState" 41 | sessionKeyGitHubUser = "githubUser" 42 | ) 43 | 44 | var htmlContent = ` 45 | 46 | 47 | 48 | 49 | prchecklist 50 | 51 | 52 |
53 |
54 |
55 | 56 | 57 | 58 | 59 | ` 60 | 61 | func init() { 62 | flag.StringVar(&sessionSecret, "session-secret", sessionSecret, "session secret (PRCHECKLIST_SESSION_SECRET)") 63 | flag.BoolVar(&behindProxy, "behind-proxy", behindProxy, "prchecklist is behind a reverse proxy (PRCHECKLIST_BEHIND_PROXY)") 64 | 65 | gob.Register(&prchecklist.GitHubUser{}) 66 | } 67 | 68 | // GitHubGateway is an interface that makes API calls to GitHub (Enterprise). 69 | // Used for OAuth interaction. 70 | type GitHubGateway interface { 71 | AuthCodeURL(state string, redirectURI *url.URL) string 72 | AuthenticateUser(ctx context.Context, code string) (*prchecklist.GitHubUser, error) 73 | GetUserFromToken(ctx context.Context, token *oauth2.Token) (*prchecklist.GitHubUser, error) 74 | } 75 | 76 | // Web is a web server implementation. 77 | type Web struct { 78 | app *usecase.Usecase 79 | github GitHubGateway 80 | sessionStore sessions.Store 81 | oauthForwarder oauthforwarder.Forwarder 82 | } 83 | 84 | // New creates a new Web. 85 | func New(app *usecase.Usecase, github GitHubGateway) *Web { 86 | cookieStore := sessions.NewCookieStore([]byte(sessionSecret)) 87 | cookieStore.Options = &sessions.Options{ 88 | Path: "/", 89 | MaxAge: int(30 * 24 * time.Hour / time.Second), 90 | HttpOnly: true, 91 | } 92 | 93 | // TODO: write doc about it 94 | // TODO be a flag variable 95 | oauthCallbackOrigin := os.Getenv("PRCHECKLIST_OAUTH_CALLBACK_ORIGIN") 96 | if oauthCallbackOrigin == "" { 97 | // deprecated 98 | oauthCallbackOrigin = "https://" + os.Getenv("PRCHECKLIST_OAUTH_CALLBACK_HOST") 99 | } 100 | u, _ := url.Parse(oauthCallbackOrigin + "/auth/callback/forward") 101 | forwarder := oauthforwarder.Forwarder{ 102 | CallbackURL: u, 103 | Secret: []byte(sessionSecret), 104 | } 105 | 106 | return &Web{ 107 | app: app, 108 | github: github, 109 | sessionStore: cookieStore, 110 | oauthForwarder: forwarder, 111 | } 112 | } 113 | 114 | // Handler is the main logic of Web. 115 | func (web *Web) Handler() http.Handler { 116 | router := mux.NewRouter() 117 | router.Handle("/", httpHandler(web.handleIndex)) 118 | router.Handle("/auth", httpHandler(web.handleAuth)) 119 | router.Handle("/auth/callback", httpHandler(web.handleAuthCallback)) 120 | router.Handle("/auth/clear", httpHandler(web.handleAuthClear)) 121 | router.Handle("/api/me", httpHandler(web.handleAPIMe)) 122 | router.Handle("/api/checklist", httpHandler(web.handleAPIChecklist)) 123 | router.Handle("/api/check", httpHandler(web.handleAPICheck)).Methods("PUT", "DELETE") 124 | router.Handle("/{owner}/{repo}/pull/{number}", httpHandler(web.handleChecklist)) 125 | router.Handle("/{owner}/{repo}/pull/{number}/{stage}", httpHandler(web.handleChecklist)) 126 | router.PathPrefix("/js/").Handler(http.FileServer(&assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo})) 127 | 128 | if testToken := os.Getenv("PRCHECKLIST_TEST_GITHUB_TOKEN"); testToken != "" { 129 | router.Handle("/debug/auth-for-testing", web.mkHandlerDebugAuthTesting(testToken)) 130 | } 131 | 132 | handler := http.Handler(router) 133 | 134 | if behindProxy { 135 | handler = handlers.ProxyHeaders(handler) 136 | } 137 | 138 | return web.oauthForwarder.Wrap(handler) 139 | } 140 | 141 | type httpError int 142 | 143 | func (he httpError) Error() string { 144 | return http.StatusText(int(he)) 145 | } 146 | 147 | type httpHandler func(w http.ResponseWriter, req *http.Request) error 148 | 149 | func (h httpHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 150 | err := h(w, req) 151 | if err != nil { 152 | log.Printf("ServeHTTP: %s (%+v)", err, err) 153 | 154 | status := http.StatusInternalServerError 155 | if he, ok := err.(httpError); ok { 156 | status = int(he) 157 | } 158 | 159 | http.Error(w, fmt.Sprintf("%+v", err), status) 160 | } 161 | } 162 | 163 | func renderJSON(w http.ResponseWriter, v interface{}) error { 164 | b, err := json.Marshal(v) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | w.Header().Add("Content-Type", "application/json") 170 | w.Write(b) 171 | return nil 172 | } 173 | 174 | func (web *Web) handleAuth(w http.ResponseWriter, req *http.Request) error { 175 | sess, _ := web.sessionStore.Get(req, sessionName) 176 | 177 | state, err := makeRandomString() 178 | if err != nil { 179 | return err 180 | } 181 | 182 | sess.Values[sessionKeyOAuthState] = state 183 | err = web.sessionStore.Save(req, w, sess) 184 | if err != nil { 185 | return err 186 | } 187 | 188 | ctx := prchecklist.RequestContext(req) 189 | 190 | callback := prchecklist.BuildURL(ctx, "/auth/callback") 191 | 192 | if returnTo := req.URL.Query().Get("return_to"); returnTo != "" { 193 | callback.RawQuery = url.Values{"return_to": {returnTo}}.Encode() 194 | } 195 | 196 | // XXX Special and ad-hoc implementation for review apps 197 | if web.oauthForwarder.CallbackURL.Host != "" && web.oauthForwarder.CallbackURL.Host != callback.Host { 198 | callback = web.oauthForwarder.CreateURL(callback.String()) 199 | } 200 | 201 | authURL := web.github.AuthCodeURL(state, callback) 202 | 203 | http.Redirect(w, req, authURL, http.StatusFound) 204 | 205 | return nil 206 | } 207 | 208 | func makeRandomString() (string, error) { 209 | buf := make([]byte, 16) 210 | _, err := rand.Read(buf) 211 | if err != nil { 212 | return "", err 213 | } 214 | return base64.RawURLEncoding.EncodeToString(buf), nil 215 | } 216 | 217 | func (web *Web) handleIndex(w http.ResponseWriter, req *http.Request) error { 218 | fmt.Fprint(w, htmlContent) 219 | return nil 220 | } 221 | 222 | func (web *Web) mkHandlerDebugAuthTesting(testToken string) httpHandler { 223 | return func(w http.ResponseWriter, req *http.Request) error { 224 | ctx := prchecklist.RequestContext(req) 225 | user, err := web.github.GetUserFromToken(ctx, &oauth2.Token{ 226 | AccessToken: testToken, 227 | }) 228 | if err != nil { 229 | return err 230 | } 231 | 232 | sess, err := web.sessionStore.Get(req, sessionName) 233 | if err != nil { 234 | return errors.Wrapf(err, "sessionStore.Get") 235 | } 236 | 237 | sess.Values[sessionKeyGitHubUser] = *user 238 | 239 | err = web.app.AddUser(ctx, *user) 240 | if err != nil { 241 | return err 242 | } 243 | 244 | err = sess.Save(req, w) 245 | if err != nil { 246 | return err 247 | } 248 | 249 | fmt.Fprintln(w, "OK") 250 | return nil 251 | } 252 | } 253 | 254 | func (web *Web) handleAuthCallback(w http.ResponseWriter, req *http.Request) error { 255 | sess, err := web.sessionStore.Get(req, sessionName) 256 | if err != nil { 257 | return errors.Wrapf(err, "sessionStore.Get") 258 | } 259 | 260 | state := req.URL.Query().Get("state") 261 | log.Printf("%#v", sess.Values) 262 | if state != sess.Values[sessionKeyOAuthState] { 263 | log.Printf("%v != %v", state, sess.Values[sessionKeyOAuthState]) 264 | return httpError(http.StatusBadRequest) 265 | } 266 | 267 | delete(sess.Values, sessionKeyOAuthState) 268 | 269 | ctx := prchecklist.RequestContext(req) 270 | 271 | code := req.URL.Query().Get("code") 272 | user, err := web.github.AuthenticateUser(ctx, code) 273 | if err != nil { 274 | return err 275 | } 276 | 277 | sess.Values[sessionKeyGitHubUser] = *user 278 | 279 | err = web.app.AddUser(ctx, *user) 280 | if err != nil { 281 | return err 282 | } 283 | 284 | err = sess.Save(req, w) 285 | if err != nil { 286 | return err 287 | } 288 | 289 | returnTo := req.URL.Query().Get("return_to") 290 | if !strings.HasPrefix(returnTo, "/") { 291 | returnTo = "/" 292 | } 293 | 294 | http.Redirect(w, req, returnTo, http.StatusFound) 295 | 296 | return nil 297 | } 298 | 299 | func (web *Web) handleAuthClear(w http.ResponseWriter, req *http.Request) error { 300 | http.SetCookie(w, &http.Cookie{ 301 | Name: sessionName, 302 | Path: "/", 303 | Expires: time.Now().Add(-1 * time.Hour), 304 | }) 305 | 306 | http.Redirect(w, req, "/", http.StatusFound) 307 | 308 | return nil 309 | } 310 | 311 | func (web *Web) getAuthInfo(w http.ResponseWriter, req *http.Request) (*prchecklist.GitHubUser, error) { 312 | sess, err := web.sessionStore.Get(req, sessionName) 313 | if err != nil { 314 | // FIXME 315 | // return nil, err 316 | return nil, nil 317 | } 318 | 319 | v, ok := sess.Values[sessionKeyGitHubUser] 320 | if !ok { 321 | return nil, nil 322 | } 323 | 324 | user, ok := v.(*prchecklist.GitHubUser) 325 | if !ok || user.Token == nil { 326 | delete(sess.Values, sessionKeyGitHubUser) 327 | return nil, sess.Save(req, w) 328 | } 329 | 330 | return user, nil 331 | } 332 | 333 | func (web *Web) handleAPIMe(w http.ResponseWriter, req *http.Request) error { 334 | u, _ := web.getAuthInfo(w, req) 335 | result := prchecklist.MeResponse{Me: u} 336 | if u != nil { 337 | ctx := prchecklist.RequestContext(req) 338 | ctx = context.WithValue(ctx, prchecklist.ContextKeyHTTPClient, u.HTTPClient(ctx)) 339 | var err error 340 | result.PullRequests, err = web.app.GetRecentPullRequests(ctx) 341 | if err != nil { 342 | return err 343 | } 344 | } 345 | 346 | return renderJSON(w, &result) 347 | } 348 | 349 | func (web *Web) handleAPIChecklist(w http.ResponseWriter, req *http.Request) error { 350 | u, err := web.getAuthInfo(w, req) 351 | if err != nil { 352 | return err 353 | } 354 | if u == nil { 355 | w.WriteHeader(http.StatusForbidden) 356 | return renderJSON(w, &prchecklist.ErrorResponse{ 357 | Type: prchecklist.ErrorTypeNotAuthed, 358 | }) 359 | } 360 | 361 | type inQuery struct { 362 | Owner string 363 | Repo string 364 | Number int 365 | Stage string 366 | } 367 | 368 | var in inQuery 369 | err = schema.NewDecoder().Decode(&in, req.URL.Query()) 370 | if err != nil { 371 | return err 372 | } 373 | if in.Stage == "" { 374 | in.Stage = "default" 375 | } 376 | 377 | ctx := prchecklist.RequestContext(req) 378 | ctx = context.WithValue(ctx, prchecklist.ContextKeyHTTPClient, u.HTTPClient(ctx)) 379 | 380 | cl, err := web.app.GetChecklist(ctx, prchecklist.ChecklistRef{ 381 | Owner: in.Owner, 382 | Repo: in.Repo, 383 | Number: in.Number, 384 | Stage: in.Stage, 385 | }) 386 | if err != nil { 387 | return err 388 | } 389 | 390 | return renderJSON(w, &prchecklist.ChecklistResponse{ 391 | Checklist: cl, 392 | Me: u, 393 | }) 394 | } 395 | 396 | func (web *Web) handleAPICheck(w http.ResponseWriter, req *http.Request) error { 397 | u, err := web.getAuthInfo(w, req) 398 | if err != nil { 399 | return err 400 | } 401 | if u == nil { 402 | return httpError(http.StatusForbidden) 403 | } 404 | 405 | type inQuery struct { 406 | Owner string 407 | Repo string 408 | Number int 409 | Stage string 410 | FeatureNumber int 411 | } 412 | 413 | if err := req.ParseForm(); err != nil { 414 | return err 415 | } 416 | 417 | var in inQuery 418 | err = schema.NewDecoder().Decode(&in, req.Form) 419 | if err != nil { 420 | return err 421 | } 422 | if in.Stage == "" { 423 | in.Stage = "default" 424 | } 425 | 426 | clRef := prchecklist.ChecklistRef{ 427 | Owner: in.Owner, 428 | Repo: in.Repo, 429 | Number: in.Number, 430 | Stage: in.Stage, 431 | } 432 | ctx := prchecklist.RequestContext(req) 433 | ctx = context.WithValue(ctx, prchecklist.ContextKeyHTTPClient, u.HTTPClient(ctx)) 434 | 435 | log.Printf("handleAPICheck: %s %+v", req.Method, in) 436 | 437 | switch req.Method { 438 | case "PUT": 439 | checklist, err := web.app.AddCheck(ctx, clRef, in.FeatureNumber, *u) 440 | if err != nil { 441 | return err 442 | } 443 | return renderJSON(w, &prchecklist.ChecklistResponse{ 444 | Checklist: checklist, 445 | Me: u, 446 | }) 447 | 448 | case "DELETE": 449 | checklist, err := web.app.RemoveCheck(ctx, clRef, in.FeatureNumber, *u) 450 | if err != nil { 451 | return err 452 | } 453 | return renderJSON(w, &prchecklist.ChecklistResponse{ 454 | Checklist: checklist, 455 | Me: u, 456 | }) 457 | 458 | default: 459 | return httpError(http.StatusMethodNotAllowed) 460 | } 461 | } 462 | 463 | func (web *Web) handleChecklist(w http.ResponseWriter, req *http.Request) error { 464 | // handle logged-out state earlier than APIs called 465 | u, _ := web.getAuthInfo(w, req) 466 | if u == nil { 467 | http.Redirect(w, req, "/auth?"+url.Values{"return_to": {req.URL.Path}}.Encode(), http.StatusFound) 468 | return nil 469 | } 470 | 471 | fmt.Fprint(w, htmlContent) 472 | return nil 473 | } 474 | -------------------------------------------------------------------------------- /lib/gateway/github.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "encoding/json" 8 | "flag" 9 | "fmt" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "time" 14 | 15 | "github.com/google/go-github/v31/github" 16 | graphqlquery "github.com/motemen/go-graphql-query" 17 | "github.com/patrickmn/go-cache" 18 | "github.com/pkg/errors" 19 | "golang.org/x/oauth2" 20 | 21 | "github.com/motemen/prchecklist/v2" 22 | ) 23 | 24 | const ( 25 | cacheDurationPullReqBase = 30 * time.Second 26 | cacheDurationPullReqFeat = 5 * time.Minute 27 | cacheDurationBlob = cache.NoExpiration 28 | ) 29 | 30 | var ( 31 | githubClientID string 32 | githubClientSecret string 33 | githubDomain string 34 | ) 35 | 36 | func getenv(key, def string) string { 37 | v := os.Getenv(key) 38 | if v == "" { 39 | return def 40 | } 41 | return v 42 | } 43 | 44 | func init() { 45 | flag.StringVar(&githubClientID, "github-client-id", os.Getenv("GITHUB_CLIENT_ID"), "GitHub client ID (GITHUB_CLIENT_ID)") 46 | flag.StringVar(&githubClientSecret, "github-client-secret", os.Getenv("GITHUB_CLIENT_SECRET"), "GitHub client secret (GITHUB_CLIENT_SECRET)") 47 | flag.StringVar(&githubDomain, "github-domain", getenv("GITHUB_DOMAIN", "github.com"), "GitHub domain (GITHUB_DOMAIN)") 48 | } 49 | 50 | // NewGitHub creates a new GitHub gateway. 51 | func NewGitHub() (*githubGateway, error) { 52 | if (githubClientID == "" || githubClientSecret == "") && os.Getenv("PRCHECKLIST_TEST_GITHUB_TOKEN") == "" { 53 | return nil, errors.New("gateway/github: both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET must be set") 54 | } 55 | 56 | var githubEndpoint = oauth2.Endpoint{ 57 | AuthURL: "https://" + githubDomain + "/login/oauth/authorize", 58 | TokenURL: "https://" + githubDomain + "/login/oauth/access_token", 59 | } 60 | 61 | return &githubGateway{ 62 | cache: cache.New(30*time.Second, 10*time.Minute), 63 | oauth2Config: &oauth2.Config{ 64 | ClientID: githubClientID, 65 | ClientSecret: githubClientSecret, 66 | Endpoint: githubEndpoint, 67 | Scopes: []string{"repo"}, 68 | }, 69 | domain: githubDomain, 70 | }, nil 71 | } 72 | 73 | type githubGateway struct { 74 | cache *cache.Cache 75 | oauth2Config *oauth2.Config 76 | domain string 77 | } 78 | 79 | type githubPullRequest struct { 80 | GraphQLArguments struct { 81 | IsBase bool `graphql:"$isBase,notnull"` 82 | } 83 | Repository *struct { 84 | GraphQLArguments struct { 85 | Owner string `graphql:"$owner,notnull"` 86 | Name string `graphql:"$repo,notnull"` 87 | } 88 | IsPrivate bool 89 | PullRequest struct { 90 | GraphQLArguments struct { 91 | Number int `graphql:"$number,notnull"` 92 | } 93 | Title string 94 | Number int 95 | Body string 96 | URL string 97 | Author struct { 98 | Login string 99 | } 100 | Assignees struct { 101 | Edges []struct { 102 | Node struct { 103 | Login string 104 | } 105 | } 106 | } `graphql:"(first: 1)"` 107 | BaseRef struct { 108 | Name string 109 | } 110 | HeadRef struct { 111 | Target struct { 112 | Tree struct { 113 | Entries []struct { 114 | Name string 115 | Oid string 116 | Type string 117 | } 118 | } `graphql:"... on Commit"` 119 | } 120 | } `graphql:"@include(if: $isBase)"` 121 | Commits struct { 122 | GraphQLArguments struct { 123 | First int `graphql:"100"` 124 | After string `graphql:"$commitsAfter"` 125 | } 126 | Edges []struct { 127 | Node struct { 128 | Commit struct { 129 | Message string 130 | Oid string 131 | } 132 | } 133 | } 134 | PageInfo struct { 135 | HasNextPage bool 136 | EndCursor string 137 | } 138 | TotalCount int 139 | } `graphql:"@include(if: $isBase)"` 140 | } 141 | } 142 | } 143 | 144 | type githubRecentPullRequests struct { 145 | Viewer struct { 146 | Repositories struct { 147 | Edges []struct { 148 | Node struct { 149 | NameWithOwner string 150 | PullRequests struct { 151 | Edges []struct { 152 | Node struct { 153 | Title string 154 | Number int 155 | URL string 156 | } 157 | } 158 | } `graphql:"(first: 5, orderBy: {field: UPDATED_AT, direction: DESC}, baseRefName: \"master\")"` 159 | } 160 | } 161 | } `graphql:"(first: 10, orderBy: {field: PUSHED_AT, direction: DESC}, affiliations: [OWNER, ORGANIZATION_MEMBER, COLLABORATOR])"` 162 | } 163 | } 164 | 165 | type githubPullRequsetVars struct { 166 | Owner string `json:"owner"` 167 | Repo string `json:"repo"` 168 | Number int `json:"number"` 169 | IsBase bool `json:"isBase"` 170 | CommitsAfter string `json:"commitsAfter,omitempty"` 171 | } 172 | 173 | type graphQLResult struct { 174 | Data interface{} 175 | Errors []struct { 176 | Message string 177 | } 178 | } 179 | 180 | var ( 181 | pullRequestQuery string 182 | recentPullRequestsQuery string 183 | ) 184 | 185 | func mustBuildGraphQLQuery(q interface{}) []byte { 186 | b, err := graphqlquery.Build(q) 187 | if err != nil { 188 | panic(err) 189 | } 190 | 191 | return b 192 | } 193 | 194 | func init() { 195 | pullRequestQuery = string(mustBuildGraphQLQuery(&githubPullRequest{})) 196 | recentPullRequestsQuery = string(mustBuildGraphQLQuery(&githubRecentPullRequests{})) 197 | } 198 | 199 | func (g githubGateway) GetBlob(ctx context.Context, ref prchecklist.ChecklistRef, sha string) ([]byte, error) { 200 | cacheKey := fmt.Sprintf("blob\000%s\000%s", ref.String(), sha) 201 | 202 | if data, ok := g.cache.Get(cacheKey); ok { 203 | if blob, ok := data.([]byte); ok { 204 | return blob, nil 205 | } 206 | } 207 | 208 | blob, err := g.getBlob(ctx, ref, sha) 209 | if err != nil { 210 | return nil, err 211 | } 212 | 213 | g.cache.Set(cacheKey, blob, cacheDurationBlob) 214 | 215 | return blob, nil 216 | } 217 | 218 | func (g githubGateway) getBlob(ctx context.Context, ref prchecklist.ChecklistRef, sha string) ([]byte, error) { 219 | gh, err := g.newGitHubClient(prchecklist.ContextClient(ctx)) 220 | if err != nil { 221 | return nil, err 222 | } 223 | 224 | blob, _, err := gh.Git.GetBlob(ctx, ref.Owner, ref.Repo, sha) 225 | if err != nil { 226 | return nil, err 227 | } 228 | 229 | content := blob.GetContent() 230 | if enc := blob.GetEncoding(); enc != "base64" { 231 | return nil, errors.Errorf("unknown encoding: %q", enc) 232 | } 233 | 234 | buf, err := base64.StdEncoding.DecodeString(content) 235 | return buf, errors.Wrap(err, "base64") 236 | } 237 | 238 | var contextKeyRepoAccessRight = &struct{ key string }{"repoRight"} 239 | 240 | type repoRight struct { 241 | owner string 242 | repo string 243 | } 244 | 245 | func contextHasRepoAccessRight(ctx context.Context, ref prchecklist.ChecklistRef) bool { 246 | if g, ok := ctx.Value(contextKeyRepoAccessRight).(repoRight); ok { 247 | return g.owner == ref.Owner && g.repo == ref.Repo 248 | } 249 | return false 250 | } 251 | 252 | func contextWithRepoAccessRight(ctx context.Context, ref prchecklist.ChecklistRef) context.Context { 253 | return context.WithValue(ctx, contextKeyRepoAccessRight, repoRight{owner: ref.Owner, repo: ref.Repo}) 254 | } 255 | 256 | func (g githubGateway) GetPullRequest(ctx context.Context, ref prchecklist.ChecklistRef, isBase bool) (*prchecklist.PullRequest, context.Context, error) { 257 | cacheKey := fmt.Sprintf("pullRequest\000%s\000%v", ref.String(), isBase) 258 | 259 | if data, ok := g.cache.Get(cacheKey); ok { 260 | if pullReq, ok := data.(*prchecklist.PullRequest); ok { 261 | if pullReq.IsPrivate && !contextHasRepoAccessRight(ctx, ref) { 262 | // something's wrong! 263 | } else { 264 | return pullReq, ctx, nil 265 | } 266 | } 267 | } 268 | 269 | pullReq, err := g.getPullRequest(ctx, ref, isBase) 270 | if err != nil { 271 | return nil, ctx, err 272 | } 273 | if isBase && pullReq.IsPrivate { 274 | // Do not cache result if the pull request is private 275 | // and isBase is true to check if the visitor has rights to 276 | // read the repo. 277 | // If isBase is false, we don't need to check vititor's rights 278 | // because GetPullRequest() with truthy isBase must be called before falsy one. 279 | return pullReq, contextWithRepoAccessRight(ctx, ref), nil 280 | } 281 | 282 | var cacheDuration time.Duration 283 | if isBase { 284 | cacheDuration = cacheDurationPullReqBase 285 | } else { 286 | cacheDuration = cacheDurationPullReqFeat 287 | } 288 | 289 | g.cache.Set(cacheKey, pullReq, cacheDuration) 290 | 291 | return pullReq, contextWithRepoAccessRight(ctx, ref), nil 292 | } 293 | 294 | func (g githubGateway) GetRecentPullRequests(ctx context.Context) (map[string][]*prchecklist.PullRequest, error) { 295 | var result githubRecentPullRequests 296 | err := g.queryGraphQL(ctx, recentPullRequestsQuery, nil, &result) 297 | if err != nil { 298 | return nil, err 299 | } 300 | 301 | pullRequests := map[string][]*prchecklist.PullRequest{} 302 | for _, edge := range result.Viewer.Repositories.Edges { 303 | repo := edge.Node 304 | if len(repo.PullRequests.Edges) == 0 { 305 | continue 306 | } 307 | pullRequests[repo.NameWithOwner] = make([]*prchecklist.PullRequest, len(repo.PullRequests.Edges)) 308 | for i, edge := range repo.PullRequests.Edges { 309 | pullReq := edge.Node 310 | pullRequests[repo.NameWithOwner][i] = &prchecklist.PullRequest{ 311 | Title: pullReq.Title, 312 | URL: pullReq.URL, 313 | Number: pullReq.Number, 314 | } 315 | } 316 | } 317 | 318 | return pullRequests, nil 319 | } 320 | 321 | func (g githubGateway) getPullRequest(ctx context.Context, ref prchecklist.ChecklistRef, isBase bool) (*prchecklist.PullRequest, error) { 322 | var qr githubPullRequest 323 | err := g.queryGraphQL(ctx, pullRequestQuery, githubPullRequsetVars{ 324 | Owner: ref.Owner, 325 | Repo: ref.Repo, 326 | Number: ref.Number, 327 | IsBase: isBase, 328 | }, &qr) 329 | if err != nil { 330 | return nil, err 331 | } 332 | if qr.Repository == nil { 333 | return nil, errors.Errorf("could not retrieve repo/pullreq") 334 | } 335 | 336 | graphqlResultToCommits := func(qr githubPullRequest) []prchecklist.Commit { 337 | commits := make([]prchecklist.Commit, len(qr.Repository.PullRequest.Commits.Edges)) 338 | for i, e := range qr.Repository.PullRequest.Commits.Edges { 339 | commits[i] = prchecklist.Commit{Message: e.Node.Commit.Message, Oid: e.Node.Commit.Oid} 340 | } 341 | return commits 342 | } 343 | 344 | pullReq := &prchecklist.PullRequest{ 345 | URL: qr.Repository.PullRequest.URL, 346 | Title: qr.Repository.PullRequest.Title, 347 | Body: qr.Repository.PullRequest.Body, 348 | IsPrivate: qr.Repository.IsPrivate, 349 | Owner: ref.Owner, 350 | Repo: ref.Repo, 351 | Number: ref.Number, 352 | Commits: graphqlResultToCommits(qr), 353 | User: prchecklist.GitHubUserSimple{ 354 | Login: qr.Repository.PullRequest.Author.Login, 355 | }, 356 | } 357 | 358 | // prefer assignee 359 | if len(qr.Repository.PullRequest.Assignees.Edges) > 0 { 360 | pullReq.User.Login = qr.Repository.PullRequest.Assignees.Edges[0].Node.Login 361 | } 362 | 363 | for _, e := range qr.Repository.PullRequest.HeadRef.Target.Tree.Entries { 364 | if e.Name == "prchecklist.yml" && e.Type == "blob" { 365 | pullReq.ConfigBlobID = e.Oid 366 | break 367 | } 368 | } 369 | 370 | for { 371 | pageInfo := qr.Repository.PullRequest.Commits.PageInfo 372 | if !pageInfo.HasNextPage { 373 | break 374 | } 375 | 376 | err := g.queryGraphQL(ctx, pullRequestQuery, githubPullRequsetVars{ 377 | Owner: ref.Owner, 378 | Repo: ref.Repo, 379 | Number: ref.Number, 380 | IsBase: isBase, 381 | CommitsAfter: pageInfo.EndCursor, 382 | }, &qr) 383 | if err != nil { 384 | return nil, err 385 | } 386 | 387 | pullReq.Commits = append(pullReq.Commits, graphqlResultToCommits(qr)...) 388 | } 389 | 390 | return pullReq, nil 391 | } 392 | 393 | func (g githubGateway) graphqlEndpoint() string { 394 | if g.domain == "github.com" { 395 | return "https://api.github.com/graphql" 396 | } 397 | 398 | return "https://" + g.domain + "/api/graphql" 399 | } 400 | 401 | func (g githubGateway) queryGraphQL(ctx context.Context, query string, variables interface{}, value interface{}) error { 402 | client := prchecklist.ContextClient(ctx) 403 | 404 | varBytes, err := json.Marshal(variables) 405 | 406 | var buf bytes.Buffer 407 | err = json.NewEncoder(&buf).Encode(map[string]string{"query": query, "variables": string(varBytes)}) 408 | if err != nil { 409 | return err 410 | } 411 | 412 | req, err := http.NewRequest("POST", g.graphqlEndpoint(), &buf) 413 | if err != nil { 414 | return err 415 | } 416 | 417 | resp, err := client.Do(req) 418 | if err != nil { 419 | return err 420 | } 421 | 422 | result := graphQLResult{ 423 | Data: value, 424 | } 425 | 426 | defer resp.Body.Close() 427 | err = json.NewDecoder(resp.Body).Decode(&result) 428 | if err != nil { 429 | return err 430 | } 431 | 432 | if len(result.Errors) > 0 { 433 | return fmt.Errorf("GraphQL error: %v", result.Errors) 434 | } 435 | 436 | return nil 437 | } 438 | 439 | func (g githubGateway) AuthCodeURL(state string, redirectURI *url.URL) string { 440 | opts := []oauth2.AuthCodeOption{} 441 | if redirectURI != nil { 442 | opts = append(opts, oauth2.SetAuthURLParam("redirect_uri", redirectURI.String())) 443 | } 444 | return g.oauth2Config.AuthCodeURL(state, opts...) 445 | } 446 | 447 | func (g githubGateway) newGitHubClient(base *http.Client) (*github.Client, error) { 448 | client := github.NewClient(base) 449 | if g.domain != "github.com" { 450 | var err error 451 | // TODO(motemen): parsing url can be done earlier 452 | client.BaseURL, err = url.Parse("https://" + g.domain + "/api/v3/") 453 | if err != nil { 454 | return nil, err 455 | } 456 | } 457 | return client, nil 458 | } 459 | 460 | func (g githubGateway) AuthenticateUser(ctx context.Context, code string) (*prchecklist.GitHubUser, error) { 461 | token, err := g.oauth2Config.Exchange(ctx, code) 462 | if err != nil { 463 | return nil, err 464 | } 465 | 466 | return g.GetUserFromToken(ctx, token) 467 | } 468 | 469 | func (g githubGateway) GetUserFromToken(ctx context.Context, token *oauth2.Token) (*prchecklist.GitHubUser, error) { 470 | client, err := g.newGitHubClient( 471 | oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)), 472 | ) 473 | if err != nil { 474 | return nil, err 475 | } 476 | 477 | u, _, err := client.Users.Get(ctx, "") 478 | if err != nil { 479 | return nil, err 480 | } 481 | 482 | return &prchecklist.GitHubUser{ 483 | ID: int(u.GetID()), 484 | Login: u.GetLogin(), 485 | AvatarURL: u.GetAvatarURL(), 486 | Token: token, 487 | }, nil 488 | } 489 | 490 | func (g githubGateway) SetRepositoryStatusAs(ctx context.Context, owner, repo, ref, contextName, state, targetURL string) error { 491 | gh, err := g.newGitHubClient(prchecklist.ContextClient(ctx)) 492 | if err != nil { 493 | return err 494 | } 495 | 496 | status := &github.RepoStatus{ 497 | State: &state, 498 | Context: &contextName, 499 | TargetURL: &targetURL, 500 | } 501 | if _, _, err = gh.Repositories.CreateStatus(ctx, owner, repo, ref, status); err != nil { 502 | return err 503 | } 504 | 505 | return nil 506 | } 507 | --------------------------------------------------------------------------------