Move TuistLab's API to the Tuist's repository (#3209)

* Move lab-api to Tuist's repository

* Centralize the Rubocop config at the root

* Use the right Ruby version on CI

* Update the Ruby version in the Gemfile

* Use Node 16.4.0

* Add missing dependency

* Remove encrypted credentials

* Some renames

* Disable test

Co-authored-by: Pedro Piñera <pepibumur@gmail.com>
This commit is contained in:
Pedro Piñera Buendía 2021-07-30 07:36:25 +02:00 committed by GitHub
parent 3d19f8b5ac
commit 43f3602e84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
245 changed files with 20748 additions and 3 deletions

2
.gitattributes vendored
View File

@ -1 +1,3 @@
CHANGELOG merge=union
yarn.lock linguist-generated

226
.github/workflows/lab.yml vendored Normal file
View File

@ -0,0 +1,226 @@
name: TuistLab
on:
push:
branches:
- main
pull_request:
paths:
- projects/lab/**
env:
RAILS_ENV: test
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
RUBY_VERSION: "3.0.1"
NODE_VERSION: "16.4.0"
CI: 1
jobs:
test:
name: Test Ruby
runs-on: ubuntu-latest
services:
postgres:
image: postgres:11-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:4.0.6
steps:
- uses: actions/checkout@v2
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
ruby-version: ${{ env.RUBY_VERSION }}
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
working-directory: projects/lab
run: |
sudo apt-get -yqq install libpq-dev
gem install bundler
bundle config path vendor/bundle
bundle install --jobs=4 --retry=3
- name: Set up database (to detect schema breaking changes)
working-directory: projects/lab
run: |
cp config/database.ci.yml config/database.yml
bundle exec rails db:create
bundle exec rails db:schema:load
- name: Migrate database
working-directory: projects/lab
run: bin/rails db:migrate
- name: Run Ruby tests
working-directory: projects/lab
run: |
bundle exec rails test
assets_precompile:
name: Assets precompile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
ruby-version: ${{ env.RUBY_VERSION }}
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2
name: Cache Yarn dependencies
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('projects/lab/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
working-directory: projects/lab
run: |
sudo apt-get -yqq install libpq-dev
gem install bundler
bundle config path vendor/bundle
bundle install --jobs=4 --retry=3
yarn install --frozen-lockfile
- name: Precompile assets
working-directory: projects/lab
run: |
bin/rails assets:precompile
detect_schema_breaking_changes:
name: GraphQL breaking changes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
ruby-version: ${{ env.RUBY_VERSION }}
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2
name: Cache Yarn dependencies
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('projects/lab/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
working-directory: projects/lab
run: |
sudo apt-get -yqq install libpq-dev
gem install bundler
bundle config path vendor/bundle
bundle install --jobs=4 --retry=3
yarn install --frozen-lockfile
- name: Precompile assets
working-directory: projects/lab
run: |
mv schema.graphql old_schema.graphql
bin/rails graphql:schema:dump
bundle exec schema_comparator verify old_schema.graphql schema.graphql
typescript_type_check:
name: Typescript type-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2
name: Cache Yarn dependencies
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('projects/lab/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
working-directory: projects/lab
run: |
sudo apt-get -yqq install libpq-dev
yarn install --frozen-lockfile
yarn install --frozen-lockfile --cwd website
- name: Typecheck
working-directory: projects/lab
run: |
yarn type-check
frontend_tests:
name: Frontend tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2
name: Cache Yarn dependencies
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('projects/lab/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
working-directory: projects/lab
run: |
sudo apt-get -yqq install libpq-dev
yarn install --frozen-lockfile
- name: Typecheck
working-directory: projects/lab
run: |
yarn test
eslint:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2
name: Cache Yarn dependencies
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('projects/lab/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
working-directory: projects/lab
run: |
sudo apt-get -yqq install libpq-dev
yarn install --frozen-lockfile
yarn install --frozen-lockfile --cwd website
- name: ESLint
working-directory: projects/lab
run: |
yarn lint

20
.github/workflows/tuist-lab-deploy.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
name: Deploy to Heroku
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Deploy to Heroku
env:
HEROKU_APP_NAME: ${{ secrets.HEROKU_APP_NAME }}
HEROKU_API_TOKEN: ${{ secrets.HEROKU_API_TOKEN }}
run: |
git remote add heroku https://heroku:$HEROKU_API_TOKEN@git.heroku.com/$HEROKU_APP_NAME.git
git push heroku --force HEAD:main

2
.nvmrc
View File

@ -1 +1 @@
16.5.0
16.5.0

View File

@ -4,6 +4,7 @@ inherit_gem:
- config/rails.yml
require:
- rubocop-minitest
- rubocop-rails
Style/ClassAndModuleChildren:
Enabled: false
@ -25,3 +26,8 @@ AllCops:
- vendor/**/*
- projects/website/node_modules/**/*
- projects/tuist/fixtures/**/*
- projects/lab/bin/**/*
- projects/lab/db/schema.rb
- projects/lab/node_modules/**/*
- projects/lab/docs/**
- projects/lab/website/**/*

View File

@ -5,6 +5,8 @@
"bradlc.vscode-tailwindcss",
"rebornix.ruby",
"misogi.ruby-rubocop",
"shopify.vscode-shadowenv"
"shopify.vscode-shadowenv",
"editorconfig.editorconfig",
"dracula-theme.theme-dracula",
]
}

View File

@ -20,5 +20,9 @@
"**/node_modules": true,
"**/.DS_Store": true
},
"editor.formatOnSave": true
"editor.formatOnSave": false,
"workbench.colorTheme": "Dracula Soft",
"editor.quickSuggestions": {
"strings": true
}
}

3
Procfile Normal file
View File

@ -0,0 +1,3 @@
release: bundle exec rake db:migrate
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -C config/sidekiq.yml

View File

@ -0,0 +1 @@
defaults

View File

@ -0,0 +1,30 @@
root = true
[*]
insert_final_newline = true
[*.{js,json}]
charset = utf-8
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[{Gemfile,Gemfile.lock,*.rb,*.ru,*.erb}]
charset = utf-8
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.{yml,yaml}]
charset = utf-8
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[{src,scripts}/**.{ts,json,js}]
end_of_line = crlf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 4

View File

@ -0,0 +1,6 @@
node_modules
app/javascript/graphql
website/public/
website/.cache/
website/node_modules/
docs

58
projects/lab/.eslintrc.js Normal file
View File

@ -0,0 +1,58 @@
module.exports = {
env: {
browser: true,
es2021: true,
jest: true,
},
globals: {
BASE_URL: 'readonly',
ENVIRONMENT: 'readonly',
BUGSNAG_FRONTEND_API_KEY: 'readonly',
},
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
extensions: [
'.js',
'.jsx',
'.ts',
'.tsx',
'.css',
'.scss',
'.css.ts',
],
moduleDirectory: ['node_modules', 'src/'],
},
},
},
extends: [
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'airbnb',
'prettier',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['react', '@typescript-eslint', 'prettier'],
rules: {
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': ['error'],
'prettier/prettier': ['error'],
'react/jsx-filename-extension': [
1,
{ extensions: ['.js', '.jsx', '.ts', '.tsx'] },
],
'react/prop-types': 'off',
'react/require-default-props': 'off',
'import/extensions': 'off',
},
};

5
projects/lab/.gitattributes vendored Normal file
View File

@ -0,0 +1,5 @@
# Mark the database schema as having been generated.
db/schema.rb linguist-generated
# Mark any vendored files as having been vendored.
vendor/* linguist-vendored

44
projects/lab/.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# If you find yourself ignoring temporary files generated by your text editor
# or operating system, you probably want to add a global ignore instead:
# git config --global core.excludesfile '~/.gitignore_global'
# Ignore bundler config.
/.bundle
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/
!/tmp/pids/.keep
# Ignore uploaded files in development.
/storage/*
!/storage/.keep
/public/assets
.byebug_history
# Ignore master key for decrypting credentials and more.
/config/master.key
/public/packs
/public/packs-test
/node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity
node_modules/
/config/credentials/production.key
.env.development
sorbet/rbi/hidden-definitions/errors.txt
TODO.md

6
projects/lab/.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 70
}

62
projects/lab/Gemfile Normal file
View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby "~> 3.0.1"
gem "bcrypt", "~> 3.1.7"
gem "bootsnap", ">= 1.4.4", require: false
gem "devise", "~> 4.8"
gem "dotenv-rails"
gem "jbuilder", "~> 2.7"
gem "js_from_routes", "~> 2.0"
gem "pg", "~> 1.1"
gem "puma", "~> 5.3.2"
gem "rails", "~> 6.1.4"
gem "redis", "~> 4.0"
gem "sass-rails", ">= 6"
gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby]
gem "tailwindcss-rails", "~> 0.3.3"
gem "rolify", "~> 6.0"
gem "pundit", "~> 2.1"
gem "webpacker", "~> 5.4"
gem "actionpack-page_caching", "~> 1.2"
gem "front_matter_parser", "~> 1.0"
gem "redcarpet", "~> 3.5"
gem "rails_admin", "~> 2.1"
gem "sidekiq", "~> 6.2"
gem "bugsnag", "~> 6.21"
gem "dig_bang", "~> 0.2.1"
gem "octokit", "~> 4.21"
gem "securecompare", "~> 1.0"
gem "sidekiq-scheduler", "~> 3.1"
gem "react-rails", "~> 2.6"
gem "graphql", "~> 1.12"
gem "graphiql-rails", group: :development
gem "rexml", "~> 3.2"
# Omniauth
gem "omniauth", "~> 2.0"
gem "omniauth-github", "~> 2.0"
gem "omniauth-rails_csrf_protection", "~> 1.0"
group :development, :test do
gem "byebug", platforms: [:mri, :mingw, :x64_mingw]
end
group :development do
gem "foreman", "~> 0.87.2"
gem "listen", "~> 3.3"
# gem "rack-mini-profiler", "~> 2.0"
gem "spring"
gem "web-console", ">= 4.1.0"
gem "graphql-schema_comparator", "~> 1.0"
end
group :test do
gem "capybara", ">= 3.26"
gem "mocha", "~> 1.12"
gem "selenium-webdriver"
gem "webdrivers"
end

421
projects/lab/Gemfile.lock Normal file
View File

@ -0,0 +1,421 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.4)
actionpack (= 6.1.4)
activesupport (= 6.1.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.4)
actionpack (= 6.1.4)
activejob (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
mail (>= 2.7.1)
actionmailer (6.1.4)
actionpack (= 6.1.4)
actionview (= 6.1.4)
activejob (= 6.1.4)
activesupport (= 6.1.4)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4)
actionview (= 6.1.4)
activesupport (= 6.1.4)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actionpack-page_caching (1.2.4)
actionpack (>= 4.0.0)
actiontext (6.1.4)
actionpack (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
nokogiri (>= 1.8.5)
actionview (6.1.4)
activesupport (= 6.1.4)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.1.4)
activesupport (= 6.1.4)
globalid (>= 0.3.6)
activemodel (6.1.4)
activesupport (= 6.1.4)
activemodel-serializers-xml (1.0.2)
activemodel (> 5.x)
activesupport (> 5.x)
builder (~> 3.1)
activerecord (6.1.4)
activemodel (= 6.1.4)
activesupport (= 6.1.4)
activestorage (6.1.4)
actionpack (= 6.1.4)
activejob (= 6.1.4)
activerecord (= 6.1.4)
activesupport (= 6.1.4)
marcel (~> 1.0.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
bcrypt (3.1.16)
bindex (0.8.1)
bootsnap (1.7.5)
msgpack (~> 1.0)
bugsnag (6.21.0)
concurrent-ruby (~> 1.0)
builder (3.2.4)
byebug (11.1.3)
capybara (3.35.3)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
childprocess (3.0.0)
concurrent-ruby (1.1.9)
connection_pool (2.2.5)
crass (1.0.6)
devise (4.8.0)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
dig_bang (0.2.1)
dotenv (2.7.6)
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
e2mmap (0.1.0)
erubi (1.10.0)
et-orbi (1.2.4)
tzinfo
execjs (2.8.1)
faraday (1.4.2)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.1)
multipart-post (>= 1.2, < 3)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.1.0)
ffi (1.15.1)
foreman (0.87.2)
front_matter_parser (1.0.0)
fugit (1.5.0)
et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.4)
globalid (0.4.2)
activesupport (>= 4.2.0)
graphiql-rails (1.7.0)
railties
sprockets-rails
graphql (1.12.13)
graphql-schema_comparator (1.0.1)
bundler (>= 1.14)
graphql (~> 1.10)
thor (>= 0.19, < 2.0)
haml (5.2.1)
temple (>= 0.8.0)
tilt
hashie (4.1.0)
i18n (1.8.10)
concurrent-ruby (~> 1.0)
jbuilder (2.11.2)
activesupport (>= 5.0.0)
jquery-rails (4.4.0)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
jquery-ui-rails (6.0.1)
railties (>= 3.2.16)
js_from_routes (2.0.4)
railties (>= 5.1, < 8)
jwt (2.2.3)
kaminari (1.2.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1)
kaminari-activerecord (= 1.2.1)
kaminari-core (= 1.2.1)
kaminari-actionview (1.2.1)
actionview
kaminari-core (= 1.2.1)
kaminari-activerecord (1.2.1)
activerecord
kaminari-core (= 1.2.1)
kaminari-core (1.2.1)
listen (3.5.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.10.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
mini_mime (>= 0.1.1)
marcel (1.0.1)
method_source (1.0.0)
mini_mime (1.1.0)
mini_portile2 (2.5.3)
minitest (5.14.4)
mocha (1.13.0)
msgpack (1.4.2)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.1.1)
nested_form (0.3.2)
nio4r (2.5.7)
nokogiri (1.11.7)
mini_portile2 (~> 2.5.0)
racc (~> 1.4)
nokogiri (1.11.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.11.7-x86_64-linux)
racc (~> 1.4)
oauth2 (1.4.7)
faraday (>= 0.8, < 2.0)
jwt (>= 1.0, < 3.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 3)
octokit (4.21.0)
faraday (>= 0.9)
sawyer (~> 0.8.0, >= 0.5.3)
omniauth (2.0.4)
hashie (>= 3.4.6)
rack (>= 1.6.2, < 3)
rack-protection
omniauth-github (2.0.0)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.7.1)
omniauth-oauth2 (1.7.1)
oauth2 (~> 1.4)
omniauth (>= 1.9, < 3)
omniauth-rails_csrf_protection (1.0.0)
actionpack (>= 4.2)
omniauth (~> 2.0)
orm_adapter (0.5.0)
pg (1.2.3)
public_suffix (4.0.6)
puma (5.3.2)
nio4r (~> 2.0)
pundit (2.1.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.5.2)
rack (2.2.3)
rack-pjax (1.1.0)
nokogiri (~> 1.5)
rack (>= 1.1)
rack-protection (2.1.0)
rack
rack-proxy (0.7.0)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (6.1.4)
actioncable (= 6.1.4)
actionmailbox (= 6.1.4)
actionmailer (= 6.1.4)
actionpack (= 6.1.4)
actiontext (= 6.1.4)
actionview (= 6.1.4)
activejob (= 6.1.4)
activemodel (= 6.1.4)
activerecord (= 6.1.4)
activestorage (= 6.1.4)
activesupport (= 6.1.4)
bundler (>= 1.15.0)
railties (= 6.1.4)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.3.0)
loofah (~> 2.3)
rails_admin (2.1.1)
activemodel-serializers-xml (>= 1.0)
builder (~> 3.1)
haml (>= 4.0, < 6)
jquery-rails (>= 3.0, < 5)
jquery-ui-rails (>= 5.0, < 7)
kaminari (>= 0.14, < 2.0)
nested_form (~> 0.3)
rack-pjax (>= 0.7)
rails (>= 5.0, < 7)
remotipart (~> 1.3)
sassc-rails (>= 1.3, < 3)
railties (6.1.4)
actionpack (= 6.1.4)
activesupport (= 6.1.4)
method_source
rake (>= 0.13)
thor (~> 1.0)
rake (13.0.3)
rb-fsevent (0.11.0)
rb-inotify (0.10.1)
ffi (~> 1.0)
react-rails (2.6.1)
babel-transpiler (>= 0.7.0)
connection_pool
execjs
railties (>= 3.2)
tilt
redcarpet (3.5.1)
redis (4.3.1)
regexp_parser (2.1.1)
remotipart (1.4.4)
responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.5)
rolify (6.0.0)
ruby2_keywords (0.0.4)
rubyzip (2.3.0)
rufus-scheduler (3.7.0)
fugit (~> 1.1, >= 1.1.6)
sass-rails (6.0.0)
sassc-rails (~> 2.1, >= 2.1.1)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
railties (>= 4.0.0)
sassc (>= 2.0)
sprockets (> 3.0)
sprockets-rails
tilt
sawyer (0.8.2)
addressable (>= 2.3.5)
faraday (> 0.8, < 2.0)
securecompare (1.0.0)
selenium-webdriver (3.142.7)
childprocess (>= 0.5, < 4.0)
rubyzip (>= 1.2.2)
semantic_range (3.0.0)
sidekiq (6.2.1)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
sidekiq-scheduler (3.1.0)
e2mmap
redis (>= 3, < 5)
rufus-scheduler (~> 3.2)
sidekiq (>= 3)
thwait
tilt (>= 1.4.0)
spring (2.1.1)
sprockets (4.0.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.2.2)
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
tailwindcss-rails (0.3.3)
rails (>= 6.0.0)
temple (0.8.2)
thor (1.1.0)
thwait (0.2.0)
e2mmap
tilt (2.0.10)
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.1.0)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webdrivers (4.6.0)
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (>= 3.0, < 4.0)
webpacker (5.4.0)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.4.2)
PLATFORMS
ruby
x86_64-darwin-19
x86_64-linux
DEPENDENCIES
actionpack-page_caching (~> 1.2)
bcrypt (~> 3.1.7)
bootsnap (>= 1.4.4)
bugsnag (~> 6.21)
byebug
capybara (>= 3.26)
devise (~> 4.8)
dig_bang (~> 0.2.1)
dotenv-rails
foreman (~> 0.87.2)
front_matter_parser (~> 1.0)
graphiql-rails
graphql (~> 1.12)
graphql-schema_comparator (~> 1.0)
jbuilder (~> 2.7)
js_from_routes (~> 2.0)
listen (~> 3.3)
mocha (~> 1.12)
octokit (~> 4.21)
omniauth (~> 2.0)
omniauth-github (~> 2.0)
omniauth-rails_csrf_protection (~> 1.0)
pg (~> 1.1)
puma (~> 5.3.2)
pundit (~> 2.1)
rails (~> 6.1.4)
rails_admin (~> 2.1)
react-rails (~> 2.6)
redcarpet (~> 3.5)
redis (~> 4.0)
rexml (~> 3.2)
rolify (~> 6.0)
sass-rails (>= 6)
securecompare (~> 1.0)
selenium-webdriver
sidekiq (~> 6.2)
sidekiq-scheduler (~> 3.1)
spring
tailwindcss-rails (~> 0.3.3)
tzinfo-data
web-console (>= 4.1.0)
webdrivers
webpacker (~> 5.4)
RUBY VERSION
ruby 3.0.1p64
BUNDLED WITH
2.2.21

View File

@ -0,0 +1,2 @@
web: bundle exec rails s
webpacker: ../bin/webpack-dev-server

16
projects/lab/Rakefile Normal file
View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative "config/application"
require "graphql/rake_task"
GraphQL::RakeTask.new(schema_name: "TuistLabSchema")
desc("Starts Rails")
task start: :environment do
system("bundle exec foreman start -f Procfile.dev") || abort
end
Rails.application.load_tasks

View File

@ -0,0 +1,15 @@
/*
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
* vendor/assets/stylesheets directory can be referenced here using a relative path.
*
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
* files in this directory. Styles in this file should be added after the last require_* statement.
* It is generally better to create a new file per style scope.
*
*= require_tree .
*= require_self
*/

View File

@ -0,0 +1,7 @@
# typed: ignore
# frozen_string_literal: true
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View File

@ -0,0 +1,7 @@
# typed: ignore
# frozen_string_literal: true
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AppController < ApplicationController
include Authenticatable
before_action :authenticate_authenticatable!
def index
render(component: "App", prerender: false)
end
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
class ApplicationController < ActionController::Base
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
module Authenticatable
extend ActiveSupport::Concern
included do
devise_group :authenticatable, contains: [:user, :project]
end
end

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
class GraphqlController < ApplicationController
include Authenticatable
# If accessing from outside this domain, nullify the session
# This allows for outside API access while preventing CSRF attacks,
# but you'll have to authenticate your user separately
# protect_from_forgery with: :null_session
def execute
if current_user.nil?
url = "#{Rails.application.config.defaults[:urls][:app]}#{user_session_path}"
error_message = "Authentication is required to interact with the GraphQL API. Authenticate through #{url}"
error_extensions = { code: "AUTHENTICATION_ERROR" }
raise GraphQL::ExecutionError.new(error_message, extensions: error_extensions)
end
variables = prepare_variables(params[:variables])
query = params[:query]
operation_name = params[:operationName]
context = {
current_user: current_user,
}
result = TuistLabSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
render(json: result)
rescue StandardError => e
raise e unless Rails.env.development?
handle_error_in_development(e)
end
private
# Handle variables in form data, JSON body, or a blank value
def prepare_variables(variables_param)
case variables_param
when String
if variables_param.present?
JSON.parse(variables_param) || {}
else
{}
end
when Hash
variables_param
when ActionController::Parameters
variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables.
when nil
{}
else
raise ArgumentError, "Unexpected parameter: #{variables_param}"
end
end
def handle_error_in_development(e)
logger.error(e.message)
logger.error(e.backtrace.join("\n"))
render(json: { errors: [{ message: e.message, backtrace: e.backtrace }], data: {} },
status: :internal_server_error)
end
end

View File

@ -0,0 +1,27 @@
# typed: ignore
# frozen_string_literal: true
module Users
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
layout "application"
def github
@user = UserCreateService.call(auth: auth_data)
if @user.persisted?
sign_in_and_redirect(@user, event: :authentication)
else
data = auth_data.except("extra")
session["devise.oauth.data"] = data
redirect_to(new_user_registration_url)
end
end
def failure
redirect_to(root_path)
end
def auth_data
request.env["omniauth.auth"]
end
end
end

View File

@ -0,0 +1,54 @@
# typed: ignore
# frozen_string_literal: true
class WebsiteController < ApplicationController
layout "website"
def landing
render("website/landing")
end
def privacy
render("website/privacy")
end
def terms
render("website/terms")
end
def cookie
render("website/cookie")
end
def acceptable_use_policy
render("website/acceptable_use_policy")
end
def changelog
@changelogs = changelogs
render("website/changelog")
end
def changelog_feed
head(:ok)
end
private
def changelogs
changelog_dir = Rails.root.join("app/content/changelog")
Dir.glob(File.join(changelog_dir, "*")).map do |file_path|
parsed_file = FrontMatterParser::Parser.parse_file(file_path)
date_string = File.basename(file_path).split("-").first
OpenStruct.new(
date: Date.strptime(date_string, "%Y%m%d"),
title: parsed_file.front_matter["title"],
type: parsed_file.front_matter["type"],
html: markdown_parser.render(parsed_file.content)
)
end
end
def markdown_parser
@markdown_parser ||= Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true, tables: true)
end
end

View File

@ -0,0 +1,95 @@
# frozen_string_literal: true
class ApiTokenDeviseStrategy < Devise::Strategies::Base
Token = Struct.new(:model_name, :id, :token) do
def self.decode(encoded)
decoded = Base64.urlsafe_decode64(encoded)
model_name, id, token = decoded.split(":")
new(model_name, id, token)
end
def valid?
[model_name, id, token].all?(&:present?)
end
def encode
Base64.urlsafe_encode64(to_a.join(":"), padding: false)
end
end
def valid?
encoded_token.present?
end
def authenticate!
return fail! unless model_is_authenticatable?
return fail! unless token_format_valid?
return fail! unless scope_match?
return fail! unless token_match?
skip_trackable
success!(model_object)
end
def store?
false
end
def clean_up_csrf?
false
end
private
def skip_trackable
env["devise.skip_trackable"] = true
end
def fail!
# TODO: Change the format to be:
# {
# "status": "error",
# "errors": [
# {
# "code": "unauthorized",
# "message": "The session is not present or is not valid."
# }
# ]
# }
super("invalid token")
end
def model_is_authenticatable?
model.class.include?(TokenAuthenticatable)
end
def token_format_valid?
decoded_token.valid?
rescue ArgumentError
false
end
def scope_match?
model.name == decoded_token.model_name
end
def model_object
@model_object ||= model.find(decoded_token.id)
end
def token_match?
Devise.secure_compare(model_object&.authentication_token, decoded_token.token)
end
def model
mapping.to
end
def decoded_token
@decoded_token ||= Token.decode(encoded_token)
end
def encoded_token
request.headers["Authorization"].to_s.remove("Bearer ")
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
argument_class Types::BaseArgument
field_class Types::BaseField
input_object_class Types::BaseInputObject
object_class Types::BaseObject
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Mutations
class OrganizationCreate < BaseMutation
description "Create a new organization"
# Arguments
argument :name, String, "The name of the organization", required: true
# Fields
field :organization, Types::OrganizationType, null: true do
description "The created organization"
end
field :errors, [String], null: false do
description "A list of errors if the creation of the organization failed"
end
def resolve(name:)
organization = OrganizationCreateService.call(name: name, admin: context[:current_user])
{
organization: organization,
errors: [],
}
end
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
class TuistLabSchema < GraphQL::Schema
mutation(Types::MutationType)
query(Types::QueryType)
# Union and Interface Resolution
def self.resolve_type(_abstract_type, _obj, _ctx)
# TODO: Implement this function
# to return the correct object type for `obj`
raise(GraphQL::RequiredImplementationMissingError)
end
# Relay-style Object Identification:
# Return a string UUID for `object`
def self.id_from_object(object, type_definition, query_ctx)
# Here's a simple implementation which:
# - joins the type name & object.id
# - encodes it with base64:
# GraphQL::Schema::UniqueWithinType.encode(type_definition.name, object.id)
end
# Given a string UUID, find the object
def self.object_from_id(id, query_ctx)
# For example, to decode the UUIDs generated above:
# type_name, item_id = GraphQL::Schema::UniqueWithinType.decode(id)
#
# Then, based on `type_name` and `id`
# find an object in your application
# ...
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
module Types
class BaseArgument < GraphQL::Schema::Argument
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
module Types
class BaseConnection < Types::BaseObject
# add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides
include GraphQL::Types::Relay::ConnectionBehaviors
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
module Types
class BaseEdge < Types::BaseObject
# add `node` and `cursor` fields, as well as `node_type(...)` override
include GraphQL::Types::Relay::EdgeBehaviors
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
module Types
class BaseEnum < GraphQL::Schema::Enum
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
module Types
class BaseField < GraphQL::Schema::Field
argument_class Types::BaseArgument
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
module Types
class BaseInputObject < GraphQL::Schema::InputObject
argument_class Types::BaseArgument
end
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
module Types
module BaseInterface
include GraphQL::Schema::Interface
edge_type_class(Types::BaseEdge)
connection_type_class(Types::BaseConnection)
field_class Types::BaseField
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
module Types
class BaseObject < GraphQL::Schema::Object
edge_type_class(Types::BaseEdge)
connection_type_class(Types::BaseConnection)
field_class Types::BaseField
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
module Types
class BaseScalar < GraphQL::Schema::Scalar
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
module Types
class BaseUnion < GraphQL::Schema::Union
edge_type_class(Types::BaseEdge)
connection_type_class(Types::BaseConnection)
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
module Types
class MutationType < Types::BaseObject
field :organization_create, mutation: Mutations::OrganizationCreate
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
module Types
module NodeType
include Types::BaseInterface
# Add the `id` field
include GraphQL::Types::Relay::NodeBehaviors
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Types
class OrganizationType < Types::BaseObject
field :id, String, null: false
field :name, String, null: false
def name
object.account.name
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Types
class QueryType < Types::BaseObject
# Add `node(id: ID!) and `nodes(ids: [ID!]!)`
include GraphQL::Types::Relay::HasNodeField
include GraphQL::Types::Relay::HasNodesField
# Add root-level fields here.
# They will be entry points for queries on your schema.
# Fields
field :me, UserType, "Returns the authenticated user.", null: false
# Resolvers
def me
context[:current_user]
end
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
module Types
class UserType < Types::BaseObject
field :id, String, null: false
field :email, String, null: false
field :avatar_url, String, null: false
end
end

View File

@ -0,0 +1,5 @@
# typed: strict
# frozen_string_literal: true
module ApplicationHelper
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
module MetaTagsHelper
def meta_title(title)
content_for(:meta_title, title)
end
def content_for_meta_title
content_for?(:meta_title) ? "TuistLab | #{content_for(:meta_title)}" : Rails.application.config.defaults.dig(
:site_metadata, :title)
end
def meta_description(description)
content_for(:meta_description, description)
end
def content_for_meta_description
content_for?(:meta_description) ? content_for(:meta_description) : Rails.application.config.defaults.dig(
:site_metadata, :description)
end
def meta_keywords(keywords)
content_for(:meta_keywords, keywords.join(","))
end
def content_for_meta_keywords
content_for?(:meta_keywords) ? content_for(:meta_keywords) : Rails.application.config.defaults.dig(:site_metadata,
:keywords)
end
def content_for_meta_image
URI.join(Rails.application.config.defaults.dig(:urls, :app),
asset_pack_path("media/images/logo-with-background.png")).to_s
end
def content_for_meta_twitter_handle
Rails.application.config.defaults.dig(:site_metadata, :twitter)
end
end

View File

@ -0,0 +1,54 @@
import React from 'react';
import {
BrowserRouter as Router,
Switch,
Route,
} from 'react-router-dom';
import { AppProvider } from '@shopify/polaris';
import ClientProvider from '../networking/ClientProvider';
import RESTClientProvider from '../networking/RESTClientProvider';
import ErrorBoundary from '../utilities/ErrorBoundary';
import HomePage from '../pages/HomePage';
import ProjectNewPage from '../pages/ProjectNewPage';
import OrganizationNewPage from '../pages/OrganizationNewPage';
import SettingsPage from '../pages/SettingsPage';
import {
projectNewPath,
organizationNewPath,
settingsPath,
} from '../utilities/routes';
import EnvironmentProvider from '../utilities/EnvironmentProvider';
import theme from '../polaris/theme';
export default function App() {
return (
<ErrorBoundary>
<RESTClientProvider>
<ClientProvider>
<AppProvider theme={theme} i18n={{}}>
<EnvironmentProvider>
<Router>
<Switch>
<Route path="/" component={HomePage} exact />
<Route
path={projectNewPath}
component={ProjectNewPage}
/>
<Route
path={organizationNewPath}
component={OrganizationNewPage}
/>
<Route
path={settingsPath}
component={SettingsPage}
/>
</Switch>
</Router>
</EnvironmentProvider>
</AppProvider>
</ClientProvider>
</RESTClientProvider>
</ErrorBoundary>
);
}

View File

@ -0,0 +1,6 @@
query Me {
me {
email
avatarUrl
}
}

View File

@ -0,0 +1,10 @@
mutation OrganizationCreate(
$organization: OrganizationCreateInput!
) {
organizationCreate(input: $organization) {
errors
organization {
name
}
}
}

View File

@ -0,0 +1,165 @@
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
export type Maybe<T> = T | null;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
const defaultOptions = {}
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
};
export type Mutation = {
__typename?: 'Mutation';
/** Create a new organization */
organizationCreate?: Maybe<OrganizationCreatePayload>;
};
export type MutationOrganizationCreateArgs = {
input: OrganizationCreateInput;
};
export type Organization = {
__typename?: 'Organization';
id: Scalars['String'];
name: Scalars['String'];
};
/** Autogenerated input type of OrganizationCreate */
export type OrganizationCreateInput = {
/** A unique identifier for the client performing the mutation. */
clientMutationId?: Maybe<Scalars['String']>;
/** The name of the organization */
name: Scalars['String'];
};
/** Autogenerated return type of OrganizationCreate */
export type OrganizationCreatePayload = {
__typename?: 'OrganizationCreatePayload';
/** A unique identifier for the client performing the mutation. */
clientMutationId?: Maybe<Scalars['String']>;
/** A list of errors if the creation of the organization failed */
errors: Array<Scalars['String']>;
/** The created organization */
organization?: Maybe<Organization>;
};
export type Query = {
__typename?: 'Query';
/** Returns the authenticated user. */
me: User;
};
export type User = {
__typename?: 'User';
avatarUrl: Scalars['String'];
email: Scalars['String'];
id: Scalars['String'];
};
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
export type MeQuery = (
{ __typename?: 'Query' }
& { me: (
{ __typename?: 'User' }
& Pick<User, 'email' | 'avatarUrl'>
) }
);
export type OrganizationCreateMutationVariables = Exact<{
organization: OrganizationCreateInput;
}>;
export type OrganizationCreateMutation = (
{ __typename?: 'Mutation' }
& { organizationCreate?: Maybe<(
{ __typename?: 'OrganizationCreatePayload' }
& Pick<OrganizationCreatePayload, 'errors'>
& { organization?: Maybe<(
{ __typename?: 'Organization' }
& Pick<Organization, 'name'>
)> }
)> }
);
export const MeDocument = gql`
query Me {
me {
email
avatarUrl
}
}
`;
/**
* __useMeQuery__
*
* To run a query within a React component, call `useMeQuery` and pass it any options that fit your needs.
* When your component renders, `useMeQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useMeQuery({
* variables: {
* },
* });
*/
export function useMeQuery(baseOptions?: Apollo.QueryHookOptions<MeQuery, MeQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<MeQuery, MeQueryVariables>(MeDocument, options);
}
export function useMeLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<MeQuery, MeQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<MeQuery, MeQueryVariables>(MeDocument, options);
}
export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
export type MeQueryResult = Apollo.QueryResult<MeQuery, MeQueryVariables>;
export const OrganizationCreateDocument = gql`
mutation OrganizationCreate($organization: OrganizationCreateInput!) {
organizationCreate(input: $organization) {
errors
organization {
name
}
}
}
`;
export type OrganizationCreateMutationFn = Apollo.MutationFunction<OrganizationCreateMutation, OrganizationCreateMutationVariables>;
/**
* __useOrganizationCreateMutation__
*
* To run a mutation, you first call `useOrganizationCreateMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useOrganizationCreateMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [organizationCreateMutation, { data, loading, error }] = useOrganizationCreateMutation({
* variables: {
* organization: // value for 'organization'
* },
* });
*/
export function useOrganizationCreateMutation(baseOptions?: Apollo.MutationHookOptions<OrganizationCreateMutation, OrganizationCreateMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<OrganizationCreateMutation, OrganizationCreateMutationVariables>(OrganizationCreateDocument, options);
}
export type OrganizationCreateMutationHookResult = ReturnType<typeof useOrganizationCreateMutation>;
export type OrganizationCreateMutationResult = Apollo.MutationResult<OrganizationCreateMutation>;
export type OrganizationCreateMutationOptions = Apollo.BaseMutationOptions<OrganizationCreateMutation, OrganizationCreateMutationVariables>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -0,0 +1,6 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="828" y="184" width="111" height="660" rx="5" fill="#F09038"/>
<rect x="670" y="274" width="111" height="560" rx="5" transform="rotate(7.60437 670 274)" fill="#F07A38"/>
<rect x="505" y="340" width="111" height="495.343" rx="5" transform="rotate(19.3331 505 340)" fill="#F06438"/>
<rect x="282.441" y="405" width="111" height="436.385" rx="5" transform="rotate(27.3432 282.441 405)" fill="#F05138"/>
</svg>

After

Width:  |  Height:  |  Size: 519 B

View File

@ -0,0 +1,5 @@
describe('it works', () => {
it('works', () => {
expect(true).toBeTruthy();
});
});

View File

@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import React from 'react';
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
concat,
} from '@apollo/client';
import { ApolloLink } from 'apollo-link';
import { createHttpLink } from 'apollo-link-http';
const httpLink = createHttpLink({ uri: '/graphql' });
const middlewareLink = new ApolloLink((operation, forward) => {
const csrfToken = document
.querySelector('meta[name=csrf-token]')
.getAttribute('content');
operation.setContext({
credentials: 'same-origin',
headers: { 'X-CSRF-Token': csrfToken },
});
return forward(operation);
});
const defaultOptions = {
watchQuery: {
fetchPolicy: 'no-cache',
errorPolicy: 'ignore',
},
query: {
fetchPolicy: 'no-cache',
errorPolicy: 'all',
},
};
const client = new ApolloClient({
link: concat(middlewareLink, httpLink),
uri: `${BASE_URL}/graphql`,
cache: new InMemoryCache(),
defaultOptions,
});
const ClientProvider = ({
children,
}: {
children: React.ReactNode;
}) => <ApolloProvider client={client}>{children}</ApolloProvider>;
export default ClientProvider;

View File

@ -0,0 +1,32 @@
import React, { useEffect, useContext } from 'react';
import type { AxiosInstance } from 'axios';
import restClient from './restClient';
const HTTPClientContext =
React.createContext<AxiosInstance>(restClient);
const RESTClientProvider = ({ children }) => {
useEffect(() => {
restClient.interceptors.request.use((config) => {
const csrfToken = document
.querySelector('meta[name=csrf-token]')
.getAttribute('content');
/* eslint-disable no-param-reassign */
config.headers['X-CSRF-Token'] = csrfToken;
config.headers['Content-Type'] = 'application/json';
config.withCredentials = true;
/* eslint-enable no-param-reassign */
return config;
});
}, [restClient]);
return (
<HTTPClientContext.Provider value={restClient}>
{children}
</HTTPClientContext.Provider>
);
};
const useRestClient = () => useContext(HTTPClientContext);
export { useRestClient, restClient };
export default RESTClientProvider;

View File

@ -0,0 +1,6 @@
import axios from 'axios';
import type { AxiosInstance } from 'axios';
const restClient: AxiosInstance = axios.create();
export default restClient;

View File

@ -0,0 +1,17 @@
import { signOutPath } from '../utilities/routes';
import restClient from './restClient';
export const redirectToSignIn = () => {
window.location.pathname = '/';
};
const signOut = async () => {
try {
await restClient.delete(signOutPath);
} catch {
console.log('Logged out');
}
redirectToSignIn();
};
export default signOut;

View File

@ -0,0 +1,26 @@
/* eslint no-console:0 */
import Bugsnag from '@bugsnag/js';
import BugsnagPluginReact from '@bugsnag/plugin-react';
import RailsUJS from '@rails/ujs';
import ReactRailsUJS from 'react_ujs';
// Styles
import '@shopify/polaris/dist/styles.css';
RailsUJS.start();
// Bugsnag
if (ENVIRONMENT !== 'development') {
Bugsnag.start({
apiKey: BUGSNAG_FRONTEND_API_KEY,
plugins: [new BugsnagPluginReact()],
});
}
// Images
require.context('../images', true);
// React
const componentRequireContext = require.context('components', true);
ReactRailsUJS.useContext(componentRequireContext);

View File

@ -0,0 +1,8 @@
/* eslint-disable */
import 'stylesheets/server-side';
const images = require.context('../images', true);
const imagePath = (name) => images(name, true);
// Rails
require('@rails/ujs').start();

View File

@ -0,0 +1,5 @@
import ReactRailsUJS from 'react_ujs';
const componentRequireContext = require.context('components', true);
ReactRailsUJS.useContext(componentRequireContext);

View File

@ -0,0 +1,321 @@
import React, { useCallback, useRef, useState } from 'react';
import {
AppProvider,
ActionList,
Card,
TextField,
TextContainer,
ContextualSaveBar,
FormLayout,
Modal,
Frame,
Layout,
Loading,
Navigation,
Page,
SkeletonBodyText,
SkeletonDisplayText,
SkeletonPage,
Toast,
TopBar,
} from '@shopify/polaris';
import { HomeMajor, OrdersMajor } from '@shopify/polaris-icons';
import { Helmet } from 'react-helmet';
import theme from '../polaris/theme';
import TopBarUserMenu from '../polaris/TopBarUserMenu';
export default function HomePage() {
const defaultState = useRef({
emailFieldValue: 'pedro@ppinera.es',
nameFieldValue: 'Jaded Pixel',
});
const skipToContentRef = useRef(null);
const [toastActive, setToastActive] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [searchActive, setSearchActive] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [mobileNavigationActive, setMobileNavigationActive] =
useState(false);
const [modalActive, setModalActive] = useState(false);
const [nameFieldValue, setNameFieldValue] = useState(
defaultState.current.nameFieldValue,
);
const [emailFieldValue, setEmailFieldValue] = useState(
defaultState.current.emailFieldValue,
);
const [, setStoreName] = useState(
defaultState.current.nameFieldValue,
);
const [supportSubject, setSupportSubject] = useState('');
const [supportMessage, setSupportMessage] = useState('');
const handleSubjectChange = useCallback(
(value) => setSupportSubject(value),
[],
);
const handleMessageChange = useCallback(
(value) => setSupportMessage(value),
[],
);
const handleDiscard = useCallback(() => {
setEmailFieldValue(defaultState.current.emailFieldValue);
setNameFieldValue(defaultState.current.nameFieldValue);
setIsDirty(false);
}, []);
const handleSave = useCallback(() => {
defaultState.current.nameFieldValue = nameFieldValue;
defaultState.current.emailFieldValue = emailFieldValue;
setIsDirty(false);
setToastActive(true);
setStoreName(defaultState.current.nameFieldValue);
}, [emailFieldValue, nameFieldValue]);
const handleNameFieldChange = useCallback((value) => {
setNameFieldValue(value);
// value && setIsDirty(true);
}, []);
const handleEmailFieldChange = useCallback((value) => {
setEmailFieldValue(value);
// value && setIsDirty(true);
}, []);
const handleSearchResultsDismiss = useCallback(() => {
setSearchActive(false);
setSearchValue('');
}, []);
const handleSearchFieldChange = useCallback((value) => {
setSearchValue(value);
setSearchActive(value.length > 0);
}, []);
const toggleToastActive = useCallback(
() => setToastActive((_toastActive) => !_toastActive),
[],
);
const toggleMobileNavigationActive = useCallback(
() =>
setMobileNavigationActive(
(_mobileNavigationActive) => !_mobileNavigationActive,
),
[],
);
const toggleIsLoading = useCallback(
() => setIsLoading((_isLoading) => !_isLoading),
[],
);
const toggleModalActive = useCallback(
() => setModalActive((_modalActive) => !_modalActive),
[],
);
const toastMarkup = toastActive ? (
<Toast onDismiss={toggleToastActive} content="Changes saved" />
) : null;
const contextualSaveBarMarkup = isDirty ? (
<ContextualSaveBar
message="Unsaved changes"
saveAction={{
onAction: handleSave,
}}
discardAction={{
onAction: handleDiscard,
}}
/>
) : null;
const searchResultsMarkup = (
<ActionList
items={[
{ content: 'Shopify help center' },
{ content: 'Community forums' },
]}
/>
);
const searchFieldMarkup = (
<TopBar.SearchField
onChange={handleSearchFieldChange}
value={searchValue}
placeholder="Search"
/>
);
const userMenuMarkup = <TopBarUserMenu />;
const topBarMarkup = (
<TopBar
showNavigationToggle
userMenu={userMenuMarkup}
searchResultsVisible={searchActive}
searchField={searchFieldMarkup}
searchResults={searchResultsMarkup}
onSearchResultsDismiss={handleSearchResultsDismiss}
onNavigationToggle={toggleMobileNavigationActive}
/>
);
const navigationMarkup = (
<Navigation location="/">
<Navigation.Section
title="Project"
items={[
{
label: 'Deploys',
icon: HomeMajor,
onClick: toggleIsLoading,
},
{
label: 'Settings',
icon: OrdersMajor,
onClick: toggleIsLoading,
},
]}
/>
</Navigation>
);
const loadingMarkup = isLoading ? <Loading /> : null;
const skipToContentTarget = (
/* eslint-disable jsx-a11y/anchor-has-content,jsx-a11y/anchor-is-valid */
<a
id="SkipToContentTarget"
ref={skipToContentRef}
tabIndex={-1}
/>
/* eslint-enable jsx-a11y/anchor-has-content,jsx-a11y/anchor-is-valid */
);
const actualPageMarkup = (
<Page title="Account">
<Layout>
{skipToContentTarget}
<Layout.AnnotatedSection
title="Account details"
description="Jaded Pixel will use this as your account information."
>
<Card sectioned>
<FormLayout>
<TextField
label="Full name"
value={nameFieldValue}
onChange={handleNameFieldChange}
/>
<TextField
type="email"
label="Email"
value={emailFieldValue}
onChange={handleEmailFieldChange}
/>
</FormLayout>
</Card>
</Layout.AnnotatedSection>
</Layout>
</Page>
);
const loadingPageMarkup = (
<SkeletonPage>
<Layout>
<Layout.Section>
<Card sectioned>
<TextContainer>
<SkeletonDisplayText size="small" />
<SkeletonBodyText lines={9} />
</TextContainer>
</Card>
</Layout.Section>
</Layout>
</SkeletonPage>
);
const pageMarkup = isLoading ? loadingPageMarkup : actualPageMarkup;
const modalMarkup = (
<Modal
open={modalActive}
onClose={toggleModalActive}
title="Contact support"
primaryAction={{
content: 'Send',
onAction: toggleModalActive,
}}
>
<Modal.Section>
<FormLayout>
<TextField
label="Subject"
value={supportSubject}
onChange={handleSubjectChange}
/>
<TextField
label="Message"
value={supportMessage}
onChange={handleMessageChange}
multiline
/>
</FormLayout>
</Modal.Section>
</Modal>
);
return (
<div>
<Helmet>
<title>TuistLab</title>
</Helmet>
<AppProvider
theme={theme}
i18n={{
Polaris: {
Avatar: {
label: 'Avatar',
labelWithInitials: 'Avatar with initials {initials}',
},
ContextualSaveBar: {
save: 'Save',
discard: 'Discard',
},
TextField: {
characterCount: '{count} characters',
},
TopBar: {
toggleMenuLabel: 'Toggle menu',
SearchField: {
clearButtonLabel: 'Clear',
search: 'Search',
},
},
Modal: {
iFrameTitle: 'body markup',
},
Frame: {
skipToContent: 'Skip to content',
navigationLabel: 'Navigation',
Navigation: {
closeMobileNavigationLabel: 'Close navigation',
},
},
},
}}
>
<Frame
topBar={topBarMarkup}
navigation={navigationMarkup}
showMobileNavigation={mobileNavigationActive}
onNavigationDismiss={toggleMobileNavigationActive}
skipToContentTarget={skipToContentRef.current}
>
{contextualSaveBarMarkup}
{loadingMarkup}
{pageMarkup}
{toastMarkup}
{modalMarkup}
</Frame>
</AppProvider>
</div>
);
}

View File

@ -0,0 +1,96 @@
import React from 'react';
import {
Card,
FormLayout,
Frame,
Layout,
Page,
TopBar,
TextField,
Form,
Button,
} from '@shopify/polaris';
import { Helmet } from 'react-helmet';
import { useForm, Controller } from 'react-hook-form';
import TopBarUserMenu from '../polaris/TopBarUserMenu';
// import { useOrganizationCreateMutation } from '../graphql/types';
type FormData = {
name: string;
};
const OrganizationNewPage = () => {
// const [
// createOrganization,
// { error: createOrganizationError, data: createOrganizationData },
// ] = useOrganizationCreateMutation();
const {
control,
formState: { errors },
handleSubmit,
} = useForm<FormData>();
const onSubmit = () => {
// createOrganization({
// variables: { organization: { name: data.name } },
// });
};
// Page Markup
const pageMarkup = (
<Page title="Create a new organization">
<Layout>
<Layout.AnnotatedSection
title="Organization details"
description="We'll need some basic information from your organization in order to create it."
>
<Card sectioned>
<Form noValidate onSubmit={handleSubmit(onSubmit)}>
<FormLayout>
<Controller
name="name"
control={control}
defaultValue=""
rules={{ required: true }}
render={({ field }) => (
<TextField
label="Organization name"
placeholder="Example: craftweg"
onChange={field.onChange}
value={field.value}
error={
errors.name?.type === 'required'
? 'The name is required to create an organization.'
: null
}
/>
)}
/>
<Button primary submit>
Submit
</Button>
</FormLayout>
</Form>
</Card>
</Layout.AnnotatedSection>
</Layout>
</Page>
);
const topBarUserMenu = <TopBarUserMenu />;
const topBarMarkup = (
<TopBar showNavigationToggle userMenu={topBarUserMenu} />
);
return (
<div>
<Helmet>
<title>New organization</title>
</Helmet>
<Frame topBar={topBarMarkup}>{pageMarkup}</Frame>
</div>
);
};
export default OrganizationNewPage;

View File

@ -0,0 +1,59 @@
import React, { useCallback, useState } from 'react';
import {
Card,
FormLayout,
Frame,
Layout,
Page,
Toast,
TopBar,
} from '@shopify/polaris';
import { Helmet } from 'react-helmet';
import TopBarUserMenu from '../polaris/TopBarUserMenu';
const ProjectNewPage = () => {
// Toast
const [toastActive, setToastActive] = useState(false);
const toggleToastActive = useCallback(
() => setToastActive((_toastActive) => !_toastActive),
[],
);
const toastMarkup = toastActive ? (
<Toast onDismiss={toggleToastActive} content="Changes saved" />
) : null;
// Page Markup
const pageMarkup = (
<Page title="Create a project">
<Layout>
<Layout.AnnotatedSection
title="Project details"
description="We'll need some basic information from your project in order to create it."
>
<Card sectioned>
<FormLayout />
</Card>
</Layout.AnnotatedSection>
</Layout>
</Page>
);
const topBarUserMenu = <TopBarUserMenu />;
const topBarMarkup = (
<TopBar showNavigationToggle userMenu={topBarUserMenu} />
);
return (
<div>
<Helmet>
<title>New project</title>
</Helmet>
<Frame topBar={topBarMarkup}>
{pageMarkup}
{toastMarkup}
</Frame>
</div>
);
};
export default ProjectNewPage;

View File

@ -0,0 +1,59 @@
import React, { useCallback, useState } from 'react';
import {
Card,
FormLayout,
Frame,
Layout,
Page,
Toast,
TopBar,
} from '@shopify/polaris';
import { Helmet } from 'react-helmet';
import TopBarUserMenu from '../polaris/TopBarUserMenu';
const SettingsPage = () => {
// Toast
const [toastActive, setToastActive] = useState(false);
const toggleToastActive = useCallback(
() => setToastActive((_toastActive) => !_toastActive),
[],
);
const toastMarkup = toastActive ? (
<Toast onDismiss={toggleToastActive} content="Changes saved" />
) : null;
// Page Markup
const pageMarkup = (
<Page title="Create a project">
<Layout>
<Layout.AnnotatedSection
title="Project details"
description="We'll need some basic information from your project in order to create it."
>
<Card sectioned>
<FormLayout />
</Card>
</Layout.AnnotatedSection>
</Layout>
</Page>
);
const topBarUserMenu = <TopBarUserMenu />;
const topBarMarkup = (
<TopBar showNavigationToggle userMenu={topBarUserMenu} />
);
return (
<div>
<Helmet>
<title>Settings</title>
</Helmet>
<Frame topBar={topBarMarkup}>
{pageMarkup}
{toastMarkup}
</Frame>
</div>
);
};
export default SettingsPage;

View File

@ -0,0 +1,66 @@
import React, { useState, useCallback } from 'react';
import { TopBar } from '@shopify/polaris';
import { useHistory } from 'react-router-dom';
import {
projectNewPath,
organizationNewPath,
settingsPath,
} from '../utilities/routes';
import signOut from '../networking/signOut';
import { useEnvironment } from '../utilities/EnvironmentProvider';
const TopBarUserMenu = () => {
const history = useHistory();
const environment = useEnvironment();
const [userMenuActive, setUserMenuActive] = useState(false);
const toggleUserMenuActive = useCallback(
() => setUserMenuActive((_userMenuActive) => !_userMenuActive),
[],
);
const userMenuActions = [
{
items: [
{
content: 'New project',
accessibilityLabel: 'Create a new project',
onAction: () => {
history.push(projectNewPath);
},
},
{
content: 'New organization',
accesibilityLabel: 'Create a new organization',
onAction: () => {
history.push(organizationNewPath);
},
},
{
content: 'Settings',
accesibility: 'Check out and change the user settings',
onAction: () => {
history.push(settingsPath);
},
},
{
content: 'Sign out',
onAction: signOut,
},
],
},
];
return (
<TopBar.UserMenu
actions={userMenuActions}
name={environment.user.email}
detail="Test"
initials="S"
avatar={environment.user.avatarUrl}
open={userMenuActive}
onToggle={toggleUserMenuActive}
/>
);
};
export default TopBarUserMenu;

View File

@ -0,0 +1,13 @@
import logo from '../images/logo-topbar.png';
const theme = {
logo: {
width: 120,
topBarSource: logo,
contextualSaveBarSource: logo,
url: `${BASE_URL}`,
accessibilityLabel: 'TuistLab',
},
};
export default theme;

View File

@ -0,0 +1,3 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

View File

@ -0,0 +1,34 @@
/* eslint-disable */
// https://davidpiesse.github.io/tailwind-md-colours/
module.exports = {
// purge: ["app/views/*/*.html.erb"],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {
colors: {
'deep-orange': '#ff5722',
'deep-orange-50': '#fbe9e7',
'deep-orange-100': '#ffccbc',
'deep-orange-200': '#ffab91',
'deep-orange-300': '#ff8a65',
'deep-orange-400': '#ff7043',
'deep-orange-500': '#ff5722',
'deep-orange-600': '#f4511e',
'deep-orange-700': '#e64a19',
'deep-orange-800': '#d84315',
'deep-orange-900': '#bf360c',
'deep-orange-100-accent': '#ff9e80',
'deep-orange-200-accent': '#ff6e40',
'deep-orange-400-accent': '#ff3d00',
'deep-orange-700-accent': '#dd2c00',
},
},
},
variants: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
};

View File

@ -0,0 +1,3 @@
declare const BASE_URL;
declare const ENVIRONMENT;
declare const BUGSNAG_FRONTEND_API_KEY;

View File

@ -0,0 +1,4 @@
declare module '*.png' {
const content: any;
export default content;
}

View File

@ -0,0 +1,4 @@
declare module '*.svg' {
const content: any;
export default content;
}

View File

@ -0,0 +1,61 @@
import React, { useContext } from 'react';
import { Frame, Loading, EmptyState, Card } from '@shopify/polaris';
import { useMeQuery } from '../graphql/types';
import type { User } from '../graphql/types';
type Environment = {
user: Pick<User, 'email' | 'avatarUrl'>;
};
const EnvironmentContext = React.createContext<
Environment | undefined
>(undefined);
const EnvironmentProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const { data, loading } = useMeQuery();
if (loading) {
return (
<Frame>
<Loading />
</Frame>
);
}
if (data) {
const environment = {
user: data.me,
};
return (
<EnvironmentContext.Provider value={environment}>
{children}
</EnvironmentContext.Provider>
);
}
return (
<Frame>
<Card sectioned>
<EmptyState
heading="Manage your inventory transfers"
action={{ content: 'Add transfer' }}
secondaryAction={{
content: 'Learn more',
url: 'https://help.shopify.com',
}}
image="https://cdn.shopify.com/s/files/1/0262/4071/2726/files/emptystate-files.png"
>
<p>
Track and receive your incoming inventory from suppliers.
</p>
</EmptyState>
</Card>
</Frame>
);
};
export const useEnvironment = (): Environment =>
useContext(EnvironmentContext);
export default EnvironmentProvider;

View File

@ -0,0 +1,22 @@
import React from 'react';
import Bugsnag from '@bugsnag/js';
let BugsnagErrorBoundary;
if (ENVIRONMENT !== 'development') {
BugsnagErrorBoundary =
Bugsnag.getPlugin('react').createErrorBoundary(React);
}
const ErrorBoundary = ({
children,
}: {
children?: React.ReactNode;
}) => {
if (ENVIRONMENT === 'development') {
return <>{children}</>;
}
return <BugsnagErrorBoundary>{children}</BugsnagErrorBoundary>;
};
export default ErrorBoundary;

View File

@ -0,0 +1,4 @@
export const projectNewPath = '/projects/new';
export const organizationNewPath = '/organizations/new';
export const settingsPath = '/settings';
export const signOutPath = '/users/sign_out';

View File

@ -0,0 +1,10 @@
# typed: ignore
# frozen_string_literal: true
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end

View File

@ -0,0 +1,97 @@
# frozen_string_literal: true
require "octokit"
require "jwt"
require "digest"
require "openssl"
require "securecompare"
require "base64"
require "dig_bang"
module GitHub
class App
Error = Class.new(StandardError)
NonExistingInstallationOnRepositoryError = Class.new(Error)
attr_reader :app_id, :app_name, :bot_login, :webhook_secret, :private_key, :oauth_id, :oauth_secret
def initialize(app_id:, app_name:, bot_login:, webhook_secret:, private_key:, oauth_id:, oauth_secret:)
@app_id = app_id
@app_name = app_name
@bot_login = bot_login
@webhook_secret = webhook_secret
@private_key = private_key
@oauth_id = oauth_id
@oauth_secret = oauth_secret
end
# Returns the URL the user can be forwarded to
# @param [String] ID of the user or organization that is installing your GitHub App.
# @param [Array<String>] The list of repositories the app should be installed into.
# @return [String] The URL to redirect the user to.
def install_url(target_id:, repository_ids: [])
query = URI.encode_www_form(
suggested_target_id: target_id,
repository_ids: repository_ids
)
"https://github.com/apps/#{app_name}/installations/new/permissions?#{query}"
end
def verify_webhook_signature(signature:, message:)
algorithm, signature = signature.split("=", 2)
return false unless algorithm == "sha1"
SecureCompare.secure_compare(signature, OpenSSL::HMAC.hexdigest(algorithm, webhook_secret, message))
end
def authenticated_client
Octokit::Client.new(bearer_token: token)
end
def authenticated_client_for_repository(repository_full_name)
cache_key = [self, "repository_token", private_key, repository_full_name]
return Octokit::Client.new(bearer_token: Rails.cache.fetch(cache_key)) if Rails.cache.exist?(cache_key)
installation_id = installation_for_repository(repository_full_name)[:id]
response = authenticated_client.create_app_installation_access_token(installation_id,
repositories: [repository_full_name.split("/").last])
token = response[:token]
expires_at = response[:expires_at]
Rails.cache.write(cache_key, token, expires_in: expires_at)
Octokit::Client.new(bearer_token: token)
end
class << self
def tuist_lab
@tuist_lab ||= GitHub::App.new(
app_id: Rails.application.credentials.devise.dig!(:omniauth, :github, :app_id),
app_name: Rails.application.credentials.devise.dig!(:omniauth, :github, :app_name),
bot_login: Rails.application.credentials.devise.dig!(:omniauth, :github, :bot_login),
webhook_secret: Rails.application.credentials.devise.dig!(:omniauth, :github, :webhook_secret),
private_key: Base64.decode64(Rails.application.credentials.devise.dig!(:omniauth, :github,
:private_key_base_64)),
oauth_id: Rails.application.credentials.devise.dig!(:omniauth, :github, :client_id),
oauth_secret: Rails.application.credentials.devise.dig!(:omniauth, :github, :client_secret)
)
end
end
private
def installation_for_repository(repository_full_name)
authenticated_client.find_repository_installation(repository_full_name)
rescue Octokit::NotFound
raise NonExistingInstallationOnRepositoryError
end
def token
Rails.cache.fetch([self, private_key, app_id], expires_in: 10.minutes) do
private_key = OpenSSL::PKey::RSA.new(self.private_key)
payload = {
# issued at time, 60 seconds in the past to allow for clock drift
iat: Time.now.to_i - 60,
exp: Time.now.to_i + (10 * 60),
iss: app_id,
}
JWT.encode(payload, private_key, "RS256")
end
end
end
end

View File

@ -0,0 +1,7 @@
# typed: ignore
# frozen_string_literal: true
class ApplicationMailer < ActionMailer::Base
default from: "noreply@tuist.io"
layout "mailer"
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class ExampleMailer < ApplicationMailer
def example_email
mail(to: "pedro@ppinera.es", subject: "Welcome to My Awesome Site")
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Account < ApplicationRecord
# Validations
validates :name, presence: true, length: { maximum: 30, minimum: 5 }, uniqueness: true,
format: { with: /\A[a-zA-Z\-\_]+\z/, message: "invalid account name" }
validates :owner_id, uniqueness: { scope: :owner_type }
# Associations
belongs_to :owner, inverse_of: :account, polymorphic: true, dependent: :destroy
has_many :projects, dependent: :destroy
end

View File

@ -0,0 +1,6 @@
# typed: ignore
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Authorization < ApplicationRecord
# include Encryptable
# attr_encrypted :token, :secret, :refresh_token
belongs_to :user, optional: true
validates :uid, uniqueness: { scope: [:provider] }
end

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
module TokenAuthenticatable
extend ActiveSupport::Concern
included do
before_save :ensure_authentication_token
end
class_methods do
attr_reader :authentication_token_attribute
def attr_authentication_token(attribute)
@authentication_token_attribute = attribute
end
end
def authentication_token
send(self.class.authentication_token_attribute.to_s)
end
def authentication_token=(token)
send("#{self.class.authentication_token_attribute}=", token)
end
def encoded_authentication_token
ApiTokenStrategy::Token.new(
self.class.name,
id,
authentication_token,
).encode
end
private
def ensure_authentication_token
if authentication_token.blank?
self.authentication_token = generate_authentication_token
end
end
def generate_authentication_token
loop do
token = Devise.friendly_token(30)
query = { self.class.authentication_token_attribute.to_sym => token }
break token unless self.class.exists?(query)
end
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class Organization < ApplicationRecord
resourcify
# Associations
has_one :account, dependent: :destroy, inverse_of: :owner, foreign_key: :owner_id
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Project < ApplicationRecord
# Concerns
include TokenAuthenticatable
# TokenAuthenticatable
attr_authentication_token :api_token
# Validations
validates :name, presence: true, length: { maximum: 30, minimum: 5 }
validates :repository_full_name,
format: { with: %r{\A[\w.@\:-~]+/[\w.@\:-~]+\z}, message: "invalid organization/repo format" }
# Associations
belongs_to :account
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Role < ApplicationRecord
# rubocop:disable Rails/HasAndBelongsToMany
has_and_belongs_to_many :users, join_table: :users_roles
# rubocop:enable Rails/HasAndBelongsToMany
belongs_to :resource,
polymorphic: true,
optional: true
validates :resource_type,
inclusion: { in: Rolify.resource_types },
allow_nil: true
scopify
end

View File

@ -0,0 +1,29 @@
# typed: ignore
# frozen_string_literal: true
class User < ApplicationRecord
# Roles
rolify
# Devise
devise :database_authenticatable,
:registerable,
:recoverable,
:rememberable,
:validatable,
# :confirmable,
:lockable,
:timeoutable,
:trackable,
:omniauthable,
omniauth_providers: [:github]
# Associations
has_one :account, dependent: :destroy, inverse_of: :owner, foreign_key: :owner_id
has_many :authorizations, dependent: :destroy
def avatar_url
hash = Digest::MD5.hexdigest(email.downcase)
"https://www.gravatar.com/avatar/#{hash}"
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class AdminPolicy < ApplicationPolicy
def access?
user&.email == "pedro@ppinera.es" || Rails.env.development?
end
end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
def show?
false
end
def create?
false
end
def new?
create?
end
def update?
false
end
def edit?
update?
end
def destroy?
false
end
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
scope.all
end
end
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class ApplicationPresenter
def self.present(*args, &block)
new(*args).present(&block)
end
def present
raise NotImplementedError
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AccountCreateService < ApplicationService
attr_reader :owner, :name
def initialize(owner:, name:)
@owner = owner
@name = name
super()
end
def call
account = Account.new(owner: owner, name: name)
account.save!
account
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class ApplicationService
def self.call(*args, &block)
new(*args).call(&block)
end
def call
raise NotImplementedError
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
class AuthorizationAddService < ApplicationService
def initialize(user:, data:)
@user = user
@data = data
super()
end
def call
@user
.authorizations
.build(
{
provider: @data["provider"],
uid: @data["uid"],
# token: data['credentials']['token'],
# secret: data['credentials']['secret'],
# refresh_token: data['credentials']['refresh_token'],
# expires: data['credentials']['expires'],
# expires_at: (Time.at(data['credentials']['expires_at']) rescue nil),
# Human readable label if a user connects multiple Google accounts
email: @data["info"]["email"],
}
)
.save
end
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class OrganizationCreateService < ApplicationService
attr_reader :name, :admin
def initialize(name:, admin:)
@name = name
@admin = admin
super()
end
def call
ActiveRecord::Base.transaction do
organization = Organization.new
admin.add_role(:admin, organization)
organization.save!
AccountCreateService.call(owner: organization, name: name)
organization
end
end
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
class ProjectCreateService < ApplicationService
end

Some files were not shown because too many files have changed in this diff Show More