Repository: yegor256/0pdd
Branch: master
Commit: 297730b34683
Files: 152
Total size: 211.3 KB
Directory structure:
gitextract_fz2x5scs/
├── .0pdd.yml
├── .gitattributes
├── .github/
│ └── workflows/
│ ├── actionlint.yml
│ ├── bashate.yml
│ ├── codecov.yml
│ ├── copyrights.yml
│ ├── markdown-lint.yml
│ ├── pdd.yml
│ ├── plantuml.yml
│ ├── rake.yml
│ ├── reuse.yml
│ ├── shellcheck.yml
│ ├── typos.yml
│ ├── xcop.yml
│ └── yamllint.yml
├── .gitignore
├── .pdd
├── .rubocop.yml
├── .rultor.yml
├── 0pdd.rb
├── Aptfile
├── Gemfile
├── LICENSE.txt
├── LICENSES/
│ └── MIT.txt
├── Procfile
├── README.md
├── REUSE.toml
├── Rakefile
├── app.json
├── assets/
│ ├── sass/
│ │ └── main.sass
│ ├── upgrades/
│ │ ├── add-namespace.xsl
│ │ └── remove-broken-issues.xsl
│ ├── xsd/
│ │ └── puzzles.xsd
│ └── xsl/
│ ├── group.xsl
│ ├── join.xsl
│ ├── puzzles.xsl
│ ├── svg.xsl
│ ├── to-close.xsl
│ └── to-submit.xsl
├── config.ru
├── cucumber.yml
├── deploy.sh
├── dynamodb-local/
│ ├── config/
│ │ └── dynamo.yml
│ ├── pom.xml
│ └── tables/
│ └── 0pdd-events.json
├── features/
│ └── step_definitions/
│ └── steps.rb
├── model/
│ ├── README.md
│ ├── fake_weights_storage.rb
│ ├── linear.rb
│ ├── predictor.rb
│ ├── pso/
│ │ ├── lib/
│ │ │ ├── function.rb
│ │ │ ├── functions/
│ │ │ │ ├── rastrigin.rb
│ │ │ │ └── schwefel.rb
│ │ │ ├── solver.rb
│ │ │ ├── version.rb
│ │ │ └── zero_vector.rb
│ │ └── pso.rb
│ └── storage.rb
├── nginx.conf.sigil
├── objects/
│ ├── clients/
│ │ ├── github.rb
│ │ ├── gitlab.rb
│ │ └── jira.rb
│ ├── diff.rb
│ ├── dynamo.rb
│ ├── git_repo.rb
│ ├── invitations/
│ │ ├── github_invitations.rb
│ │ └── github_organization_invitations.rb
│ ├── jobs/
│ │ ├── job.rb
│ │ ├── job_commiterrors.rb
│ │ ├── job_detached.rb
│ │ ├── job_emailed.rb
│ │ ├── job_recorded.rb
│ │ └── job_starred.rb
│ ├── log.rb
│ ├── maybe_text.rb
│ ├── puzzles.rb
│ ├── storage/
│ │ ├── cached_storage.rb
│ │ ├── logged_storage.rb
│ │ ├── once_storage.rb
│ │ ├── s3.rb
│ │ ├── safe_storage.rb
│ │ ├── sync_storage.rb
│ │ ├── upgraded_storage.rb
│ │ └── versioned_storage.rb
│ ├── templates/
│ │ ├── github_tickets_body.haml
│ │ ├── gitlab_tickets_body.haml
│ │ └── jira_tickets_body.haml
│ ├── tickets/
│ │ ├── commit_tickets.rb
│ │ ├── emailed_tickets.rb
│ │ ├── logged_tickets.rb
│ │ ├── milestone_tickets.rb
│ │ ├── sentry_tickets.rb
│ │ ├── tagged_tickets.rb
│ │ └── tickets.rb
│ ├── truncated.rb
│ ├── user_error.rb
│ └── vcs/
│ ├── github.rb
│ ├── gitlab.rb
│ └── jira.rb
├── renovate.json
├── test/
│ ├── fake_github.rb
│ ├── fake_gitlab.rb
│ ├── fake_log.rb
│ ├── fake_repo.rb
│ ├── fake_storage.rb
│ ├── fake_tickets.rb
│ ├── test_0pdd.rb
│ ├── test__helper.rb
│ ├── test_cached_storage.rb
│ ├── test_commit_tickets.rb
│ ├── test_credentials.rb
│ ├── test_diff.rb
│ ├── test_diff_complicated.rb
│ ├── test_git_repo.rb
│ ├── test_github.rb
│ ├── test_github_invitations.rb
│ ├── test_github_tickets.rb
│ ├── test_gitlab.rb
│ ├── test_job.rb
│ ├── test_job_commiterrors.rb
│ ├── test_job_detached.rb
│ ├── test_job_emailed.rb
│ ├── test_log.rb
│ ├── test_logged_storage.rb
│ ├── test_logged_tickets.rb
│ ├── test_maybe_text.rb
│ ├── test_milestone_tickets.rb
│ ├── test_once_storage.rb
│ ├── test_puzzles.rb
│ ├── test_safe_storage.rb
│ ├── test_sentry_tickets.rb
│ ├── test_svg.rb
│ ├── test_truncated.rb
│ ├── test_upgraded_storage.rb
│ └── test_versioned_storage.rb
├── test-assets/
│ └── puzzles/
│ ├── closes-one-puzzle.xml
│ ├── ignores-unknown-issues.xml
│ ├── notify-unknown-open-issues.xml
│ ├── simple.xml
│ ├── submits-old-puzzles.xml
│ ├── submits-ranked-puzzles.xml
│ └── submits-three-tickets.xml
├── version.rb
└── views/
├── _footer.haml
├── _header.haml
├── error.haml
├── error_400.haml
├── index.haml
├── item.haml
├── layout.haml
├── log.haml
└── not_found.haml
================================================
FILE CONTENTS
================================================
================================================
FILE: .0pdd.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
errors:
- yegor256@gmail.com
# alerts:
# github:
# - yegor256
tags:
- pdd
- bug
================================================
FILE: .gitattributes
================================================
# Check out all text files in UNIX format, with LF as end of line
# Don't change this file. If you have any ideas about it, please
# submit a separate issue about it and we'll discuss.
* text=auto eol=lf
*.java ident
*.xml ident
*.png binary
================================================
FILE: .github/workflows/actionlint.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: actionlint
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
actionlint:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Download actionlint
id: get_actionlint
run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
shell: bash
- name: Check workflow files
run: ${{ steps.get_actionlint.outputs.executable }} -color
shell: bash
================================================
FILE: .github/workflows/bashate.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: bashate
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
bashate:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.14
- run: pip install bashate
- run: |
readarray -t files < <(find . -name '*.sh')
bashate -i E006,E003 "${files[@]}"
================================================
FILE: .github/workflows/codecov.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: codecov
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
codecov:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- run: sudo apt-get install --yes libmagic-dev
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.9
bundler-cache: true
- run: bundle config set --global path "$(pwd)/vendor/bundle"
- run: bundle install --no-color
- run: bundle exec rake
- uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
================================================
FILE: .github/workflows/copyrights.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: copyrights
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
copyrights:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: yegor256/copyrights-action@0.0.12
================================================
FILE: .github/workflows/markdown-lint.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: markdown-lint
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
markdown-lint:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: DavidAnson/markdownlint-cli2-action@v23.2.0
================================================
FILE: .github/workflows/pdd.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: pdd
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
pdd:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: volodya-lombrozo/pdd-action@master
================================================
FILE: .github/workflows/plantuml.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: plantuml
'on':
push:
paths:
- '**.puml'
branches:
- master
permissions:
contents: write
jobs:
plantuml:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- name: Checkout Source
uses: actions/checkout@v6
- name: Generate SVG Diagrams
uses: holowinski/plantuml-github-action@main
with:
args: -v -tsvg doc/*.puml
- name: Commit changes
uses: EndBug/add-and-commit@v10
with:
author_name: ${{ github.actor }}
author_email: ${{ github.event.pusher.email }}
message: 'Diagram generated'
add: 'doc/*'
================================================
FILE: .github/workflows/rake.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: rake
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
test:
strategy:
matrix:
os: [ubuntu-24.04]
ruby: [3.3]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- run: sudo apt-get install --yes libmagic-dev
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle config set --global path "$(pwd)/vendor/bundle"
- run: bundle install --no-color
- run: bundle exec rake
================================================
FILE: .github/workflows/reuse.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: reuse
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
reuse:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: fsfe/reuse-action@v6
================================================
FILE: .github/workflows/shellcheck.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: shellcheck
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
shellcheck:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: ludeeus/action-shellcheck@master
================================================
FILE: .github/workflows/typos.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: typos
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
typos:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: crate-ci/typos@v1.46.1
================================================
FILE: .github/workflows/xcop.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: xcop
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
xcop:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: g4s8/xcop-action@master
================================================
FILE: .github/workflows/yamllint.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
name: yamllint
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
yamllint:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: ibiqlik/action-yamllint@v3
================================================
FILE: .gitignore
================================================
*.gem
*.iml
.bundle/
.claude/
.DS_Store
.idea/
.sass-cache/
.yardoc/
coverage/
doc/
node_modules/
rdoc/
target/
vendor/
================================================
FILE: .pdd
================================================
--source=.
--verbose
--exclude README.md
--exclude coverage/**/*
--exclude assets/**/*
--exclude model/data/**/*
--rule min-words:10
--rule min-estimate:15
--rule max-estimate:90
================================================
FILE: .rubocop.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
AllCops:
Exclude:
- 'bin/**/*'
- 'assets/**/*'
- 'vendor/**/*'
DisplayCopNames: true
TargetRubyVersion: 2.6.0
NewCops: enable
SuggestExtensions: false
plugins:
- rubocop-rake
- rubocop-minitest
- rubocop-performance
Layout/MultilineOperationIndentation:
Enabled: false
Layout/EmptyLineAfterGuardClause:
Enabled: false
Naming/MethodParameterName:
MinNameLength: 1
Style/CommandLiteral:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Layout/IndentationWidth:
Enabled: false
Minitest/EmptyLineBeforeAssertionMethods:
Enabled: false
Layout/ElseAlignment:
Enabled: false
Naming/PredicateMethod:
Enabled: false
Layout/EndAlignment:
Enabled: false
Lint/RescueException:
Enabled: false
Metrics/MethodLength:
Max: 50
Metrics/ClassLength:
Max: 200
Exclude:
- "test/test_*.rb"
Metrics/AbcSize:
Max: 60
Metrics/BlockLength:
Max: 100
Layout/MultilineMethodCallIndentation:
Enabled: false
Metrics/CyclomaticComplexity:
Max: 11
Metrics/PerceivedComplexity:
Max: 11
Layout/LineLength:
Max: 120
Style/OpenStructUse:
Enabled: false
Style/ComparableClamp:
Enabled: false
================================================
FILE: .rultor.yml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
---
# yamllint disable rule:line-length
docker:
image: yegor256/rultor-image:1.24.0
assets:
config.yml: yegor256/home#assets/0pdd/config.yml
id_rsa: yegor256/home#assets/heroku-key
id_rsa.pub: yegor256/home#assets/heroku-key.pub
install: |-
git config --global user.email "server@0pdd.com"
git config --global user.name "0pdd.com"
sudo gem install pdd
pdd -f /dev/null
bundle install --no-color
release:
pre: false
sensitive:
- config.yml
script: |-
[[ "${tag}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit -1
bundle exec rake
git remote add dokku dokku@dokku.0pdd.com:zeropdd
rm -rf ~/.ssh
mkdir ~/.ssh
mv ../id_rsa ../id_rsa.pub ~/.ssh
chmod -R 600 ~/.ssh/*
echo -e "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile=/dev/null" > ~/.ssh/config
git fetch
sed -i "s/BUILD/${tag}/g" ./version.rb
git add ./version.rb
git commit --no-verify -m 'build number set'
cp ../config.yml config.yml
git add config.yml
bundle exec ruby test/test_credentials.rb
git commit --no-verify -m 'config.yml'
git push -f dokku $(git symbolic-ref --short HEAD):master
git reset HEAD~1
rm -rf config.yml
curl -f --connect-timeout 15 -k --retry 5 --retry-delay 30 https://www.0pdd.com > /dev/null
merge:
script: |-
bundle exec rake
================================================
FILE: 0pdd.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
$stdout.sync = true
require 'glogin'
require 'haml'
require 'json'
require 'mail'
require 'net/http'
require 'octokit'
require 'ostruct'
require 'qbash'
require 'rack'
require 'sentry-ruby'
require 'sass'
require 'sinatra'
require 'sinatra/cookies'
require 'tmpdir'
require 'uri'
require_relative 'version'
require_relative 'objects/log'
require_relative 'objects/dynamo'
require_relative 'objects/git_repo'
require_relative 'objects/user_error'
require_relative 'objects/vcs/github'
require_relative 'objects/vcs/gitlab'
require_relative 'objects/clients/github'
require_relative 'objects/clients/gitlab'
require_relative 'objects/jobs/job'
require_relative 'objects/jobs/job_detached'
require_relative 'objects/jobs/job_emailed'
require_relative 'objects/jobs/job_recorded'
require_relative 'objects/jobs/job_starred'
require_relative 'objects/jobs/job_commiterrors'
require_relative 'objects/tickets/tickets'
require_relative 'objects/tickets/tagged_tickets'
require_relative 'objects/tickets/emailed_tickets'
require_relative 'objects/tickets/logged_tickets'
require_relative 'objects/tickets/commit_tickets'
require_relative 'objects/tickets/sentry_tickets'
require_relative 'objects/tickets/milestone_tickets'
require_relative 'objects/storage/s3'
require_relative 'objects/storage/safe_storage'
require_relative 'objects/storage/sync_storage'
require_relative 'objects/storage/logged_storage'
require_relative 'objects/storage/versioned_storage'
require_relative 'objects/storage/upgraded_storage'
require_relative 'objects/storage/cached_storage'
require_relative 'objects/storage/once_storage'
require_relative 'objects/invitations/github_invitations'
require_relative 'test/fake_storage'
configure do
Haml::Options.defaults[:format] = :xhtml
config = if ENV['RACK_ENV'] == 'test'
{
'testing' => true,
'github' => {
'token' => '--the-token--',
'client_id' => '?',
'client_secret' => '?'
},
'gitlab' => {
'token' => '--the-token--',
'client_id' => '?',
'client_secret' => '?'
},
'jira' => {
'token' => '--the-token--',
'client_id' => '?',
'client_secret' => '?'
},
'sentry' => '',
's3' => {
'region' => '?',
'bucket' => '?',
'key' => '?',
'secret' => '?'
},
'id_rsa' => ''
}
else
config = YAML.safe_load(File.open(File.join(File.dirname(__FILE__), 'config.yml')))
raise 'Missing configuration file config.yml' if config.nil?
config
end
if ENV['RACK_ENV'] != 'test'
Sentry.init do |c|
c.dsn = config['sentry']
c.release = VERSION
end
end
set :config, config
if config['smtp']
Mail.defaults do
delivery_method(
:smtp,
address: config['smtp']['host'],
port: config['smtp']['port'],
user_name: config['smtp']['user'],
password: config['smtp']['password'],
domain: '0pdd.com',
enable_starttls_auto: true
)
end
end
set :server_settings, timeout: 25
set :github, Github.new(config).client
set :gitlab, GitlabClient.new(config).client
set :dynamo, Dynamo.new(config).aws
set :glogin, GLogin::Auth.new(
config['github']['client_id'],
config['github']['client_secret'],
'https://www.0pdd.com/github-callback'
)
set :ruby_version, qbash('ruby -e "print RUBY_VERSION"')
set :git_version, qbash('git --version | cut -d" " -f 3')
set :temp_dir, Dir.mktmpdir('0pdd')
if ENV['RACK_ENV'] != 'test'
Thread.new do
loop do
sleep(10)
Net::HTTP.get_response(URI('https://www.0pdd.com/ping-github'))
rescue Exception
# If we reach this point, we must not even try to
# do anything. Here we must quietly ignore everything
# and let the daemon go to the next cycle.
end
end
end
end
use Rack::Deflater
# @todo #572:1h rewind is removed from rack 3.0, so it is moved to
# rewindableInput for now, but it is better to check another solutions
use Rack::RewindableInput::Middleware
before '/*' do
@locals = {
ver: VERSION,
login_link: settings.glogin.login_uri
}
if cookies[:glogin]
begin
@locals[:user] = GLogin::Cookie::Closed.new(
cookies[:glogin],
settings.config['github']['encryption_secret']
).to_user
rescue OpenSSL::Cipher::CipherError
@locals.delete(:user)
end
end
end
get '/github-callback' do
code = params[:code]
redirect('/') if code.nil?
cookies[:glogin] = GLogin::Cookie::Open.new(
settings.glogin.user(code),
settings.config['github']['encryption_secret']
).to_s
redirect to('/')
end
get '/logout' do
cookies.delete(:glogin)
redirect to('/')
end
get '/' do
projects = qbash(
"(sort /tmp/0pdd-done.txt 2>/dev/null || echo '')\
| uniq"
).split("\n").reject(&:empty?)
haml :index, layout: :layout, locals: merged(
title: '0pdd',
ruby_version: settings.ruby_version,
git_version: settings.git_version,
remaining: settings.github.rate_limit.remaining,
tail: projects.last(10).reverse
)
end
get '/robots.txt' do
'User-agent: *
Disallow: /snapshot'
end
get '/version' do
VERSION
end
get '/invitation' do
repo = repo_name(params[:repo])
ghi = GithubInvitations.new(settings.github)
invitations = ghi.accept_single_invitation(repo)
return invitations.join('\n') unless invitations.empty?
"Could not find invitation for @#{repo}. It is either invitation already
accepted OR 0pdd is not added as a collaborator"
end
get '/p' do
vcs = vcs_name(params[:vcs])
name = repo_name(params[:name])
xml = storage(name, vcs).load
Nokogiri::XSLT(File.read('assets/xsl/puzzles.xsl')).transform(
xml,
[
'version', "'#{VERSION}'",
'project', "'#{name}'",
'length', xml.to_s.length.to_s
]
).to_s
end
get '/xml' do
content_type 'text/xml'
vcs = vcs_name(params[:vcs])
storage(repo_name(params[:name]), vcs).load.to_s
end
get '/log' do
vcs = vcs_name(params[:vcs])
repo = repo_name(params[:name])
haml :log, layout: :layout, locals: merged(
title: repo,
repo: repo,
log: Log.new(settings.dynamo, repo, vcs),
since: params[:since] ? params[:since].to_i : Time.now.to_i + 1
)
end
get '/snapshot' do
content_type 'text/xml'
master = params[:branch]
vcs = vcs_name(params[:vcs])
name = repo_name(params[:name])
uri = "git@github.com:#{name}.git"
uri = "git@gitlab.com:#{name}.git" if vcs == 'gitlab'
begin
repo = GitRepo.new(
uri: uri,
name: name,
id_rsa: settings.config['id_rsa'],
dir: settings.temp_dir,
master: master || 'master'
)
repo.push
xml = repo.xml
xml.xpath('//processing-instruction("xml-stylesheet")').remove
xml.to_s
rescue StandardError => e
error 400, "Could not get snapshot for #{name}: #{e.message}"
end
end
get '/log-item' do
vcs = vcs_name(params[:vcs])
repo = repo_name(params[:repo])
tag = params[:tag]
error 404 if tag.nil?
log = Log.new(settings.dynamo, repo, vcs)
error 404 unless log.exists(tag)
haml :item, layout: :layout, locals: merged(
title: tag,
repo: repo,
item: log.get(tag)
)
end
get '/log-delete' do
redirect '/' if @locals[:user].nil? || @locals[:user][:login] != 'yegor256'
repo = repo_name(params[:name])
vcs = vcs_name(params[:vcs])
Log.new(settings.dynamo, repo, vcs).delete(params[:time].to_i, params[:tag])
redirect "/log?name=#{repo}"
end
get '/svg' do
response.headers['Cache-Control'] = 'no-cache, private'
content_type 'image/svg+xml'
name = repo_name(params[:name])
vcs = vcs_name(params[:vcs])
Nokogiri::XSLT(File.read('assets/xsl/svg.xsl')).transform(
storage(name, vcs).load, ['project', "'#{name}'"]
).to_s
end
get '/ping-github' do
content_type 'text/plain'
gh = settings.github
return if gh.rate_limit.remaining < 1000
invitations = GithubInvitations.new(gh)
invitations.accept
invitations.accept_orgs
msgs = gh.notifications.map do |n|
reason = n['reason']
repo = n['repository']['full_name']
puts "GitHub notification in #{repo}: #{reason} #{n['updated_at']} #{n['subject']['type']}"
if reason == 'mention'
issue = n['subject']['url'].gsub(%r{^.+/issues/}, '').to_i
comment = n['subject']['latest_comment_url'].gsub(%r{^.+/comments/}, '').to_i
begin
json = gh.issue_comment(repo, comment)
body = json['body']
if body.start_with?("@#{gh.login}") && json['user']['login'] != gh.login
gh.add_comment(
repo,
issue,
"> #{body.gsub(/\s+/, ' ').gsub(/^(.{100,}?).*$/m, '\1...')}\n\n" \
"I see you're talking to me, but I can't reply since I'm not a chat bot."
)
puts "Replied to #{repo}##{issue}"
end
rescue Octokit::NotFound => e
puts "Failed: #{e.message}"
next
end
end
"#{repo}: #{reason}"
end
gh.mark_notifications_as_read(last_read_at: Time.now)
"#{msgs.join("\n")}\n"
end
get '/hook/github' do
'This URL expects POST requests from GitHub
WebHook: https://developer.github.com/webhooks/'
end
post '/hook/github' do
is_from_github = request.env['HTTP_USER_AGENT']&.start_with?('GitHub-Hookshot')
is_push_event = request.env['HTTP_X_GITHUB_EVENT'] == 'push'
unless is_from_github && is_push_event
return [
400,
'Please, only register push events from GitHub webhook'
]
end
request.env['rack.input'].rewind if request.env['rack.input'].respond_to?(:rewind)
request.body.rewind unless request.env['rack.input'].respond_to?(:rewind)
json = JSON.parse(
case request.content_type
when 'application/x-www-form-urlencoded'
payload = params[:payload]
# see https://docs.github.com/en/webhooks/using-webhooks/creating-webhooks
if payload.nil?
return [
400,
'URL-encoded content is expected in the "payload" query parameter, but it is not provided'
]
end
payload
when 'application/json'
request.body.read
else
raise "Invalid content-type: \"#{request.content_type}\""
end
)
github = GithubRepo.new(settings.github, json, settings.config)
return [400, "No access to #{github.repo.name}"] unless github.exists?
unless ENV['RACK_ENV'] == 'test'
process_request(github) if github.repo.change_in_master?
puts "GitHub hook from #{github.repo.name} to branch #{github.repo.target}"
end
ignore = ''
ignore = 'Push is not to master branch, nothing is done. ' unless github.repo.change_in_master?
"#{ignore}Thanks #{github.repo.name}"
end
get '/hook/gitlab' do
'This URL expects POST requests from Gitlab
WebHook: https://docs.gitlab.com/ee/user/project/integrations/webhooks.html'
end
post '/hook/gitlab' do
is_from_gitlab = request.env['HTTP_USER_AGENT'].start_with?('GitLab')
is_push_event = request.env['HTTP_X_GITLAB_EVENT'] == 'Push Hook'
unless is_from_gitlab && is_push_event
return [
400,
'Please, only register push events from Gitlab webhook'
]
end
request.env['rack.input'].rewind if request.env['rack.input'].respond_to?(:rewind)
request.body.rewind unless request.env['rack.input'].respond_to?(:rewind)
json = JSON.parse(
case request.content_type
when 'application/x-www-form-urlencoded'
params[:payload]
when 'application/json'
request.body.read
else
raise "Invalid content-type: \"#{request.content_type}\""
end
)
gitlab = GitlabRepo.new(settings.gitlab, json, settings.config)
return [400, "No access to #{gitlab.repo.name}"] unless gitlab.exists?
unless ENV['RACK_ENV'] == 'test'
process_request(gitlab) if gitlab.repo.change_in_master?
puts "Gitlab hook from #{gitlab.repo.name} to branch #{gitlab.repo.target}"
end
ignore = ''
ignore = 'Push is not to master branch, nothing is done. ' unless gitlab.repo.change_in_master?
"#{ignore}Thanks #{gitlab.repo.name}"
end
get '/css/*.css' do
content_type 'text/css', charset: 'utf-8'
file = params[:splat].first
template = File.join(File.absolute_path('./assets/sass/'), "#{file}.sass")
Sass::Engine.new(File.read(template)).render
end
get '/puzzles.xsd' do
content_type 'application/xml', charset: 'utf-8'
File.read('assets/xsd/puzzles.xsd')
end
not_found do
status 404
content_type 'text/html', charset: 'utf-8'
haml :not_found, layout: :layout, locals: merged(
title: 'Page not found'
)
end
error do
status 503
e = env['sinatra.error']
Sentry.capture_exception(e) unless e.is_a?(UserError)
haml(
:error,
layout: :layout,
locals: merged(
title: 'error',
error: "#{e.message}\n\t#{e.backtrace.join("\n\t")}"
)
)
end
def repo_name(name)
error 404 if name.nil?
error 404 unless %r{^[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_.]+$}.match?(name)
name.strip
end
def vcs_name(name)
return 'github' if name.nil?
name.strip.downcase
end
def merged(hash)
out = @locals.merge(hash)
out[:local_assigns] = out
out
end
def storage(repo, vcs)
file_name = vcs == 'github' ? repo : "#{vcs}-#{repo}"
SyncStorage.new(
UpgradedStorage.new(
SafeStorage.new(
OnceStorage.new(
CachedStorage.new(
VersionedStorage.new(
if ENV['RACK_ENV'] == 'test'
FakeStorage.new
else
LoggedStorage.new(
S3.new(
"#{file_name}.xml",
settings.config['s3']['bucket'],
settings.config['s3']['region'],
settings.config['s3']['key'],
settings.config['s3']['secret']
),
Log.new(settings.dynamo, repo, vcs)
)
end,
VERSION
),
File.join('/tmp/0pdd-xml-cache', file_name)
)
)
),
VERSION
)
)
end
def process_request(vcs)
JobDetached.new(
vcs,
JobCommitErrors.new(
vcs,
JobEmailed.new(
vcs,
JobRecorded.new(
vcs,
JobStarred.new(
vcs,
Job.new(
vcs,
storage(vcs.repo.name, vcs.name),
SentryTickets.new(
EmailedTickets.new(
vcs,
CommitTickets.new(
vcs,
TaggedTickets.new(
vcs,
LoggedTickets.new(
vcs,
Log.new(settings.dynamo, vcs.repo.name, vcs.name),
MilestoneTickets.new(
vcs,
Tickets.new(vcs)
)
)
)
)
)
)
)
)
)
)
)
).proceed
end
================================================
FILE: Aptfile
================================================
git
================================================
FILE: Gemfile
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
source 'https://rubygems.org'
gem 'atlassian-jwt', '~>0.2.1'
gem 'aws-sdk-dynamodb', '~>1.111'
gem 'aws-sdk-s3', '~>1.176'
gem 'crack', '~>1.0'
gem 'faraday', '~>2.14'
gem 'gitlab', '~>6.0'
gem 'glogin', '~>0.16'
gem 'haml', '~>5.2'
gem 'jira-ruby', '~>3.0'
gem 'mail', '~>2.8'
gem 'matrix', '~>0.4'
gem 'minitest', '~>6.0', require: false
gem 'minitest-reporters', '~>1.7', require: false
gem 'net-smtp', '~>0.5'
gem 'nokogiri', '~>1.18'
gem 'octokit', '~>10.0'
gem 'ostruct', '~>0.6'
gem 'pdd', '~>0.24'
gem 'qbash', '~>0.4'
gem 'rack', '~>3.1'
gem 'rack-test', '~>2.2'
gem 'rackup', '~>2.2'
gem 'rake', '~>13.2', require: false
gem 'rubocop', '~>1.69', require: false
gem 'rubocop-minitest', '~>0.38', require: false
gem 'rubocop-performance', '~>1.26', require: false
gem 'rubocop-rake', '~>0.7', require: false
gem 'sass', '~>3.7'
gem 'sentry-ruby', '~>6.2'
gem 'simplecov', '~>0.22'
gem 'simplecov-cobertura', '~>3.1'
gem 'sinatra', '~>4.0'
gem 'sinatra-contrib', '~>4.0'
gem 'sprockets', '~>4.2'
gem 'veils', '~>0.4'
gem 'webrick', '~>1.9'
gem 'xcop', '~>0.7'
================================================
FILE: LICENSE.txt
================================================
(The MIT License)
Copyright (c) 2016-2026 Yegor Bugayenko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the 'Software'), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: LICENSES/MIT.txt
================================================
(The MIT License)
Copyright (c) 2016-2026 Yegor Bugayenko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the 'Software'), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Procfile
================================================
web: bundle exec rackup config.ru -p $PORT
cron: curl -s https://www.0pdd.com/ping-github
================================================
FILE: README.md
================================================
# Puzzle Driven Development (PDD) GitHub Chatbot
[](https://www.elegantobjects.org)
[](https://www.rultor.com/p/yegor256/0pdd)
[](https://www.jetbrains.com/ruby/)
[](https://github.com/yegor256/0pdd/actions/workflows/rake.yml)
[](https://www.sixnines.io/h/574a)
[](https://www.rehttp.net/i?u=http%3A%2F%2Fwww.0pdd.com%2Fhook%2Fgithub)
[](https://www.0pdd.com/p?name=yegor256/0pdd)
[](https://codeclimate.com/github/yegor256/0pdd/maintainability)
[](https://codecov.io/github/yegor256/0pdd?branch=master)
[](https://hitsofcode.com/view/github/yegor256/0pdd)
[](https://www.codacy.com/gh/yegor256/0pdd/dashboard)
Read this blog post first:
[PDD in Action](https://www.yegor256.com/2017/04/05/pdd-in-action.html).
TL;DR:
1. Your boss tells you to fix issue `#42`
1. You do it, but not completely (you have no time, you are lazy, etc)
1. You put `TODO #42:30min bla-bla-bla` into the codebase (in a pull request)
1. CI checks that you didn't break the format of the `TODO`
(reuse our [`pdd.yml`][pdd.yml])
1. You merge the pull request
1. The bot picks up the `TODO` and creates issue `#43` (new one)
1. The boss asks your friend to fix `#43`
1. The friend fixes it, and merges
1. The `TODO` is gone from the codebase
1. The bot closes the issue `#43`
[0pdd.com](https://www.0pdd.com) is a hosted service that
finds new "puzzles" in your repository and posts them as GitHub
issues. To start using it just create a
[Webhook](https://developer.github.com/webhooks/creating/) in your repository
just for `push` events with `https://www.0pdd.com/hook/github` payload URL and
`application/json` content type.
Then, add [@0pdd](https://github.com/0pdd) GitHub user as a
[collaborator] to your repository, if it's private
(you don't need this for a public repository).
If your invitation is not accepted by [@0pdd](https://github.com/0pdd)
within 30 minutes, visit this address:
`https://0pdd.com/invitation?repo={REPO_FULL_NAME}`, where `REPO_FULL_NAME`
is the full name of your repo, e.g., `yegor256/0pdd`.
Then, add a `@todo` [puzzle](https://www.yegor256.com/2009/03/04/pdd.html)
to the source code (format it [right](https://github.com/teamed/pdd)).
Then, `git push` something to the master branch and see what happens.
You should see a new
issue created in your repository by [@0pdd](https://github.com/0pdd).
You can find the dependency tree of all puzzles in your repository
here: `https://www.0pdd.com/p?name=yegor256/0pdd` (just replace the name
of the repo in the URL).
Don't forget to add that cute little badge to your `README.md`, just
like we did here in this repo (see above). The Markdown you need
will look like this (replace `yegor256/0pdd` with GitHub coordinates
of your own repository):
```markdown
[](https://www.0pdd.com/p?name=yegor256/0pdd)
```
## How to configure?
The only way to configure 0pdd is to add `.0pdd.yml` file to the
root directory of your `master` branch (see
[this one](https://github.com/yegor256/0pdd/blob/master/.0pdd.yml)
as a live example).
It has to be a [YAML](https://en.wikipedia.org/wiki/YAML)
file with the following
optional parameters inside:
```yaml
threshold: 10
model: true
errors:
- yegor256@gmail.com
alerts:
suppress:
- on-found-puzzle
- on-lost-puzzle
- on-scope
github:
- yegor256
format:
- short-title
- title-length=100
tags:
- pdd
- bug
```
The element `threshold` allows you to limit the number of issues created
from the puzzles in your code. In the example above, each time the appropriate
push event is sent to your webhook up to 10 issues will be created regardless
of the number of puzzles found in the code. If this limit is not set,
`threshold` is assumed to be equal to 256.
Section `errors` allows you to specify a list of email addresses that will
receive notifications when PDD processing fails for your repo. This is
a useful feature, since programmers often make
mistakes in PDD puzzle formatting. We recommend using it.
Section `alerts` allows you to specify users that will be notified when
new PDD puzzles show up. By default we will just submit GitHub tickets
and that's it. If you add `github` subsection there, you can list GitHub
users who will be "notified": their GitHub nicknames will be added to
each puzzle description and GitHub will notify them by email.
Subsection `suppress` lets you make 0pdd quieter where necessary:
* `on-found-puzzle`: stay quiet when a new puzzle is discovered
* `on-lost-puzzle`: stay quiet when a puzzle is gone
* `on-scope`: stay quiet when child puzzles change statuses
The `model` option is used by 0pdd
to opt in to an ML model that prioritizes puzzles generated by `pdd`.
If you would like to opt in to puzzle prioritization, add this option
to your `.0pdd.yml`.
[pdd](https://github.com/yegor256/pdd) is the tool that parses your source
code files. You can configure its behavior by adding `.pdd` file to the
root directory of the repository. Take
[this one](https://github.com/yegor256/0pdd/blob/master/.pdd), as an example.
The `format` section helps you instruct 0pdd about GitHub issues formatting.
These options are supported:
* `short-title`: issue title will not include file name and line numbers
* `title-length=...`: you may configure the length of the title of GitHub
issues we create. Minimum length is 30, maximum is 255. Any other values
will be silently ignored. The default length is 60.
The `tags` section lists GitHub labels that will automatically be attached
to all new issues we create. If you don't have those labels in your GitHub
repository, they will automatically be created.
To exclude files from analysis, create a `.pdd` file with the following content:
```text
--exclude=path/to/file.txt
```
See: [pdd usage](https://github.com/cqfn/pdd?tab=readme-ov-file#usage)
## What to expect?
Pay attention to the comments @0pdd posts to your commits. They will
contain valuable information about its recent actions. If something goes
wrong, you will receive exception messages there. Please, post them here
as new issues.
Remember that GitHub triggers us only when you do `git push`. This means that
if you make a number of commits, we will process them all together. Only the
latest one will be commented. It may not be the one with new puzzles though.
After we create GitHub issues you can modify their titles and descriptions. You
can work with them as with any other issues. We will touch them only one
more time, when the puzzle disappears from the source code. At that moment
we will try to close the issue. If it is already closed, nothing will happen.
However, it's not a good practice to close them manually. You better remove
the necessary puzzle from the source code and let us close the issue.
## How to contribute?
It is a Ruby project.
First, install
[Java] SDK 8+,
[Maven 3.2+](https://maven.apache.org/),
[Ruby 2.3+](https://www.ruby-lang.org/en/documentation/installation/),
[Rubygems](https://rubygems.org/pages/download),
and
[Bundler](https://bundler.io/).
Then:
```bash
bundle update
bundle exec rake
```
The build has to be clean. If it's not,
[submit an issue](https://github.com/yegor256/0pdd/issues).
Then, make your changes, make sure the build is still clean,
and [submit a pull request][guidelines].
To run it locally:
```bash
bundle exec rake run
```
If you want to run it on your own machine, you will need to add this
`config.yml` file to the root directory of this repository:
```yaml
s3:
region: us-east-1
bucket: xml.0pdd.com
key: AKIAI..........UTSQA
secret: Z2FbKB..........viCKaYo4H..........vva21
sentry: https://....@sentry.io/229223
dynamo:
region: us-east-1
key: AKIAI..........UTSQA
secret: Z2FbKB..........viCKaYo4H..........vva21
github:
client_id: b96a3b5..........87e
client_secret: be61c471154e2..........66f434d33e0f63a5f
encryption_secret: some-random-text
login: 0pdd
token: GitHub-Password
smtp:
host: email-smtp.us-east-1.amazonaws.com
port: 587
user: smtp_user
password: smtp_password
id_rsa: |
... RSA key goes here, in ASCII format
```
We add this file to the repository while deploying to Heroku,
see how it's done in `.rultor.yml`.
## How to install in Heroku
Don't forget this:
```bash
heroku buildpacks:add --index 1 https://github.com/heroku/heroku-buildpack-apt
```
[pdd.yml]: https://github.com/yegor256/0pdd/blob/master/.github/workflows/pdd.yml
[guidelines]: https://www.yegor256.com/2014/04/15/github-guidelines.html
[Java]: https://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html
[collaborator]: https://help.github.com/articles/inviting-collaborators-to-a-personal-repository/
================================================
FILE: REUSE.toml
================================================
# SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko
# SPDX-License-Identifier: MIT
version = 1
[[annotations]]
path = [
".DS_Store",
".gitattributes",
".gitignore",
".pdd",
"**.json",
"**.md",
"**.png",
"**.sigil",
"**.svg",
"**.txt",
"**/.DS_Store",
"**/.gitignore",
"**/.pdd",
"**/*.csv",
"**/*.jpg",
"**/*.json",
"**/*.md",
"**/*.pdf",
"**/*.png",
"**/*.svg",
"**/*.txt",
"**/*.vm",
"**/CNAME",
"**/Gemfile.lock",
"**/Procfile",
"Aptfile",
"doc/integration.puml",
"Gemfile.lock",
"nginx.conf.sigil",
"Procfile",
"README.md",
"renovate.json",
]
precedence = "override"
SPDX-FileCopyrightText = "Copyright (c) 2025 Yegor Bugayenko"
SPDX-License-Identifier = "MIT"
================================================
FILE: Rakefile
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'rubygems'
require 'rake'
require 'rake/clean'
require_relative 'objects/dynamo'
ENV['RACK_ENV'] = 'test'
task default: %i[clean test rubocop xcop]
require 'rake/testtask'
desc 'Run all unit tests'
Rake::TestTask.new(test: :dynamo) do |test|
Rake::Cleaner.cleanup_files(['coverage'])
test.libs << 'lib' << 'test'
test.pattern = 'test/**/test_*.rb'
test.verbose = false
test.warning = false
end
require 'rubocop/rake_task'
desc 'Run RuboCop on all directories'
RuboCop::RakeTask.new(:rubocop) do |task|
task.fail_on_error = true
end
require 'xcop/rake_task'
desc 'Validate all XML/XSL/XSD/HTML files for formatting'
Xcop::RakeTask.new :xcop do |task|
task.includes = ['**/*.xml', '**/*.xsl', '**/*.xsd', '**/*.html']
task.excludes = ['target/**/*', 'coverage/**/*', 'vendor/**/*']
end
desc 'Start DynamoDB Local server'
task :dynamo do
FileUtils.rm_rf('dynamodb-local/target')
pid = Process.spawn('mvn', 'install', '--quiet', chdir: 'dynamodb-local')
at_exit do
`kill -TERM #{pid}`
puts "DynamoDB Local killed in PID #{pid}"
end
begin
status = Dynamo.new.aws.describe_table(
table_name: '0pdd-events'
)[:table][:table_status]
puts "DynamoDB Local table: #{status}"
rescue Exception => e
puts e.message
sleep(5)
retry
end
puts "DynamoDB Local is running in PID #{pid}"
end
desc 'Sleep endlessly after the start of DynamoDB Local server'
task :sleep do
loop do
sleep(5)
puts 'Still alive...'
end
end
desc 'Run website'
task run: :dynamo do
`rerun -b "RACK_ENV=test rackup"`
end
================================================
FILE: app.json
================================================
{
"healthchecks": {
"web": [
{
"attempts": 3,
"description": "Checking if the app responds to the /robots.txt endpoint",
"name": "web check",
"path": "/robots.txt",
"type": "startup"
}
]
}
}
================================================
FILE: assets/sass/main.sass
================================================
// SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
// SPDX-License-Identifier: MIT
body
background-color: white
color: #111
font-family: monospace
font-size: 18px
margin: 0
padding: 1em
a
color: blue
&:hover
color: inherit
.logo
height: 92px
width: 92px
.center
height: 19em
left: 0
margin: auto
max-width: 100%
position: absolute
right: 0
text-align: center
width: 20em
.versions
img
height: 1em
================================================
FILE: assets/upgrades/add-namespace.xsl
================================================
================================================
FILE: assets/upgrades/remove-broken-issues.xsl
================================================
================================================
FILE: assets/xsd/puzzles.xsd
================================================
================================================
FILE: assets/xsl/group.xsl
================================================
================================================
FILE: assets/xsl/join.xsl
================================================
truefalseunknown
================================================
FILE: assets/xsl/puzzles.xsl
================================================
"
end
end
mail.cc = repo_owner_email if repo_owner_email
mail.deliver!
puts "Email sent to #{email}"
end
raise e
end
private
def repo_user_login
@vcs.repo.name.split('/').first
end
def user_email(username)
@vcs.user(username)[:email]
end
end
================================================
FILE: objects/jobs/job_recorded.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Job that records all requests.
#
class JobRecorded
def initialize(vcs, job)
@vcs = vcs
@job = job
end
def proceed
@job.proceed
open('/tmp/0pdd-done.txt', 'a+') do |f|
f.puts(@vcs.repo.name)
end
end
end
================================================
FILE: objects/jobs/job_starred.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Job that stars the repo.
# API: http://octokit.github.io/octokit.rb/method_list.html
#
class JobStarred
def initialize(vcs, job)
@vcs = vcs
@job = job
end
def proceed
output = @job.proceed
@vcs.star
output
end
end
================================================
FILE: objects/log.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'base64'
require 'nokogiri'
require 'aws-sdk-dynamodb'
require_relative 'dynamo'
require_relative '../version'
#
# Log.
#
class Log
def initialize(dynamo, repo, vcs = 'github')
@dynamo = dynamo
# @todo #312:30min Be sure to handle the use case where projects from
# different vcs have the same . This will cause a conflict.
@vcs = (vcs || 'github').downcase
@repo = @vcs == 'github' ? repo : Base64.encode64(repo + @vcs).gsub(%r{[\s=/]+}, '')
raise 'You need to specify your cloud VCS' unless ['github'].include?(@vcs)
end
def put(tag, text)
@dynamo.put_item(
table_name: '0pdd-events',
item: {
'repo' => @repo,
'vcs' => @vcs,
'time' => Time.now.to_i,
'tag' => tag,
'text' => "#{text} /#{VERSION}"
}
)
end
def get(tag)
@dynamo.query(
table_name: '0pdd-events',
index_name: 'tags',
select: 'ALL_ATTRIBUTES',
limit: 1,
expression_attribute_values: {
':r' => @repo,
':t' => tag
},
key_condition_expression: 'repo=:r and tag=:t'
).items[0]
end
def exists(tag)
!@dynamo.query(
table_name: '0pdd-events',
index_name: 'tags',
select: 'ALL_ATTRIBUTES',
limit: 1,
expression_attribute_values: {
':r' => @repo,
':t' => tag
},
key_condition_expression: 'repo=:r and tag=:t'
).items.empty?
end
def delete(time, tag)
@dynamo.delete_item(
table_name: '0pdd-events',
key: {
'repo' => @repo,
'time' => time
},
expression_attribute_values: {
':t' => tag
},
condition_expression: 'tag=:t'
)
end
def list(since = Time.now.to_i)
@dynamo.query(
table_name: '0pdd-events',
select: 'ALL_ATTRIBUTES',
limit: 25,
scan_index_forward: false,
expression_attribute_names: {
'#time' => 'time'
},
expression_attribute_values: {
':r' => @repo,
':t' => since
},
key_condition_expression: 'repo=:r and #time<:t'
)
end
end
================================================
FILE: objects/maybe_text.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Maybe text
#
class MaybeText
def initialize(text_if_present, maybe, exclude_if: false)
@maybe = maybe
@text = text_if_present
@exclude_if = exclude_if
end
def to_s
if @maybe.nil? || @maybe.empty? || @maybe == @exclude_if
''
else
@text
end
end
end
================================================
FILE: objects/puzzles.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'json'
require 'crack'
require 'nokogiri'
require_relative '../model/linear'
#
# Puzzles in XML/S3
# @todo #532:60min Implement a decorator for optional model configuration load.
# Let's implement a class that decorates `Puzzles` and
# based on presence of `model: true` attribute in YAML config, decides
# whether the puzzles should be ranked or not.
# Don't forget to remove this puzzle.
#
class Puzzles
def initialize(repo, storage)
@repo = repo
@storage = storage
t = repo.config && repo.config['threshold'].to_i
@threshold = t.positive? && t < 256 ? t : 256
end
# Find out which puzzles deservers to become new tickets and submit
# them to the repository (GitHub, for example). Also, find out which
# puzzles are no longer active and remove them from GitHub.
def deploy(tickets)
xml = join(@storage.load, @repo.xml)
xml = group(xml)
save(xml)
expose(xml, tickets)
end
private
# Save new XML into the storage, replacing the existing one.
def save(xml)
@storage.save(xml)
end
# Join existing XML with the snapshot just arrived from PDD
# toolkit output after the analysis of the code base. New
# elements are added as elements. They later inside the
# method join() will be placed to the right positions and will
# either replace existing ones of will become new puzzles.
def join(before, snapshot)
after = Nokogiri::XML(before.to_s)
target = after.xpath('/puzzles')[0]
snapshot.xpath('//puzzle').each do |p|
p.name = 'extra'
target.add_child(p)
end
after
end
# Merge elements with elements in the XML. Some
# extras will be simply deleted, while others will become new
# puzzles.
def group(xml)
Nokogiri::XSLT(File.read('assets/xsl/group.xsl')).transform(
Nokogiri::XSLT(File.read('assets/xsl/join.xsl')).transform(xml)
)
end
# Take some puzzles from the XML and either close their tickets in GitHub
# or create new tickets.
def expose(xml, tickets)
seen = []
Kernel.loop do
puzzles = xml.xpath(
[
'//puzzle[@alive="false" and issue',
'and issue != "unknown" and not(issue/@closed)',
seen.map { |i| "and id != '#{i}'" }.join(' '),
']'
].join(' ')
)
break if puzzles.empty?
puzzle = puzzles[0]
puzzle.search('issue')[0]['closed'] = Time.now.iso8601 if tickets.close(puzzle)
save(xml)
end
seen = []
Kernel.loop do
puzzles = xml.xpath(
[
'//puzzle[@alive="true" and (not(issue) or issue="unknown")',
seen.map { |i| "and id != '#{i}'" }.join(' '),
']'
].join(' ')
)
break if puzzles.empty?
puzzle = puzzles[0]
id = puzzle.xpath('id')[0].text
seen << id
issue = tickets.submit(puzzle)
next if issue.nil?
puzzle.search('issue').remove
puzzle.add_child(
"#{issue[:number]}"
)
save(xml)
end
end
end
================================================
FILE: objects/storage/cached_storage.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# XML cached in a temporary file.
#
class CachedStorage
def initialize(origin, file)
@origin = origin
@file = file
end
def load
if File.exist?(@file)
begin
content = File.read(@file)
rescue StandardError => e
raise "Failed to read #{@file} due to #{e.cause.inspect}"
end
xml = Nokogiri::XML(content)
else
xml = @origin.load
write(xml)
end
xml
end
def save(xml)
FileUtils.rm_rf(@file)
@origin.save(xml)
write(xml.to_s)
end
private
def write(xml)
FileUtils.mkdir_p(File.dirname(@file))
File.write(@file, xml)
end
end
================================================
FILE: objects/storage/logged_storage.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Storage that is logged.
#
class LoggedStorage
def initialize(origin, log)
@origin = origin
@log = log
end
def load
@origin.load
end
def save(xml)
@origin.save(xml)
@log.put(
"save-#{Time.now.to_i}",
"Saved XML, puzzles:#{xml.xpath('//puzzle[@alive="true"]').size}/\
#{xml.xpath('//puzzle').size}, chars:#{xml.to_s.length}, \
date:#{xml.xpath('/*/@date')[0].text}, \
version:#{xml.xpath('/*/@version')[0].text}"
)
end
end
================================================
FILE: objects/storage/once_storage.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Saves only once, if the content wasn't really changed.
#
class OnceStorage
def initialize(origin)
@origin = origin
end
def load
@origin.load
end
def save(xml)
@origin.save(xml) if load.to_s != xml.to_s
end
end
================================================
FILE: objects/storage/s3.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'aws-sdk-s3'
require 'nokogiri'
require_relative '../../version'
#
# S3 storage.
#
class S3
def initialize(ocket, bucket, region, key, secret)
@object = Aws::S3::Resource.new(
region: region,
credentials: Aws::Credentials.new(key, secret)
).bucket(bucket).object(ocket)
end
def load
Nokogiri::XML(
if @object.exists?
data = @object.get.body
puts "S3 #{data.size} from #{@object.bucket_name}/#{@object.key}"
data
else
puts "Empty puzzles for #{@object.bucket_name}/#{@object.key}"
''
end
)
end
def save(xml)
data = xml.to_s
@object.put(body: data)
puts "S3 #{data.size} to #{@object.bucket_name}/#{@object.key} \
(#{xml.xpath('//puzzle').size} puzzles)"
end
end
================================================
FILE: objects/storage/safe_storage.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'nokogiri'
#
# Safe, XSD validated, storage.
#
class SafeStorage
def initialize(origin)
@origin = origin
@xsd = Nokogiri::XML::Schema(File.read('assets/xsd/puzzles.xsd'))
end
def load
@origin.load
end
def save(xml)
@origin.save(valid(xml))
end
private
def valid(xml)
errors = @xsd.validate(xml).each(&:message)
raise "XML has #{errors.length} errors\nw#{errors.join("\n")}\n#{xml}" unless errors.empty?
xml
end
end
================================================
FILE: objects/storage/sync_storage.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Thread-safe storage.
#
class SyncStorage
def initialize(origin)
@origin = origin
@mutex = Mutex.new
end
def load
@mutex.synchronize { @origin.load }
end
def save(xml)
@mutex.synchronize { @origin.save(xml) }
end
end
================================================
FILE: objects/storage/upgraded_storage.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Storage that upgrades itself on load.
#
class UpgradedStorage
def initialize(origin, version)
@origin = origin
@version = version
end
def load
xml = @origin.load
if xml.xpath('/*/@version')[0] != @version
%w[remove-broken-issues add-namespace].each do |xsl|
xml = Nokogiri::XSLT(
File.read("assets/upgrades/#{xsl}.xsl")
).transform(xml)
end
save(xml)
end
xml
end
def save(xml)
@origin.save(xml)
end
end
================================================
FILE: objects/storage/versioned_storage.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Storage that adds version to the XML when it gets saved.
#
class VersionedStorage
def initialize(origin, version)
@origin = origin
@version = version
end
def load
xml = @origin.load
root = xml.xpath('/*')[0]
unless root['date']
root['date'] = '2016-12-08T12:00:49Z'
root['version'] = '0.0.0'
end
xml
end
def save(xml)
root = xml.xpath('/*')[0]
root['date'] = Time.now.iso8601
root['version'] = @version
@origin.save(xml)
end
end
================================================
FILE: objects/templates/github_tickets_body.haml
================================================
-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
-# SPDX-License-Identifier: MIT
The puzzle `#{puzzle.xpath('id')[0].text}` |
from ##{puzzle.xpath('ticket')[0].text} has to be resolved: |
\
#{url}
\
The puzzle was created by #{puzzle.xpath('author')[0].text} on |
#{Time.parse(puzzle.xpath('time')[0].text).strftime('%d-%b-%y')}. |
\
#{MaybeText.new("Estimate: #{puzzle.xpath('estimate')[0].text} minutes, ", puzzle.xpath('estimate')[0].text, exclude_if: '0')} |
#{MaybeText.new("role: #{puzzle.xpath('role')[0].text}.", puzzle.xpath('role')[0].text, exclude_if: 'IMP')} |
\
If you have any technical questions, don't ask me, |
submit new tickets instead. The task will be \"done\" when |
the problem is fixed and the text of the puzzle is |
_removed_ from the source code. Here is more about |
[PDD](http://www.yegor256.com/2009/03/04/pdd.html) and |
[about me](http://www.yegor256.com/2017/04/05/pdd-in-action.html). |
================================================
FILE: objects/templates/gitlab_tickets_body.haml
================================================
-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
-# SPDX-License-Identifier: MIT
The puzzle `#{puzzle.xpath('id')[0].text}` |
from ##{puzzle.xpath('ticket')[0].text} has to be resolved: |
\
#{url}
\
The puzzle was created by #{puzzle.xpath('author')[0].text} on |
#{Time.parse(puzzle.xpath('time')[0].text).strftime('%d-%b-%y')}. |
\
#{MaybeText.new("Estimate: #{puzzle.xpath('estimate')[0].text} minutes, ", puzzle.xpath('estimate')[0].text, exclude_if: '0')} |
#{MaybeText.new("role: #{puzzle.xpath('role')[0].text}.", puzzle.xpath('role')[0].text, exclude_if: 'IMP')} |
\
If you have any technical questions, don't ask me, |
submit new tickets instead. The task will be \"done\" when |
the problem is fixed and the text of the puzzle is |
_removed_ from the source code. Here is more about |
[PDD](http://www.yegor256.com/2009/03/04/pdd.html) and |
[about me](http://www.yegor256.com/2017/04/05/pdd-in-action.html). |
================================================
FILE: objects/templates/jira_tickets_body.haml
================================================
-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
-# SPDX-License-Identifier: MIT
The puzzle `#{puzzle.xpath('id')[0].text}` |
from ##{puzzle.xpath('ticket')[0].text} has to be resolved: |
\
#{url}
\
The puzzle was created by #{puzzle.xpath('author')[0].text} on |
#{Time.parse(puzzle.xpath('time')[0].text).strftime('%d-%b-%y')}. |
\
#{MaybeText.new("Estimate: #{puzzle.xpath('estimate')[0].text} minutes, ", puzzle.xpath('estimate')[0].text, exclude_if: '0')} |
#{MaybeText.new("role: #{puzzle.xpath('role')[0].text}.", puzzle.xpath('role')[0].text, exclude_if: 'IMP')} |
\
If you have any technical questions, don't ask me, |
submit new tickets instead. The task will be \"done\" when |
the problem is fixed and the text of the puzzle is |
_removed_ from the source code. Here is more about |
[PDD](http://www.yegor256.com/2009/03/04/pdd.html) and |
[about me](http://www.yegor256.com/2017/04/05/pdd-in-action.html). |
================================================
FILE: objects/tickets/commit_tickets.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Tickets that post into commits.
#
class CommitTickets
def initialize(vcs, tickets)
@vcs = vcs
@commit = vcs.repo.head_commit_hash
@tickets = tickets
end
def notify(issue, message)
@tickets.notify(issue, message)
end
def submit(puzzle)
done = @tickets.submit(puzzle)
return done if suppressed_repo?
@vcs.create_commit_comment(
@commit,
"Puzzle `#{puzzle.xpath('id')[0].text}` discovered in \
[`#{puzzle.xpath('file')[0].text}`](#{@vcs.file_link(puzzle.xpath('file')[0].text)}) \
and submitted as ##{done[:number]}. Please, remember that the puzzle was not \
necessarily added in this particular commit. Maybe it was added earlier, but \
we discovered it only now."
)
done
end
def close(puzzle)
done = @tickets.close(puzzle)
if done && !opts.include?('on-lost-puzzle')
@vcs.create_commit_comment(
@commit,
"Puzzle `#{puzzle.xpath('id')[0].text}` disappeared from \
[`#{puzzle.xpath('file')[0].text}`](#{@vcs.file_link(puzzle.xpath('file')[0].text)}), \
that's why I closed ##{puzzle.xpath('issue')[0].text}. \
Please, remember that the puzzle was not necessarily removed in this \
particular commit. Maybe it happened earlier, but we discovered this fact \
only now."
)
end
done
end
private
def opts
array = @vcs.repo.config.dig('alerts', 'suppress')
array.nil? || !array.is_a?(Array) ? [] : array
end
def suppressed_repo?
suppressed_options = %w[on-found-puzzle on-scope]
suppressed_options.any? { |item| opts.include?(item) }
end
end
================================================
FILE: objects/tickets/emailed_tickets.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Tickets that email when submitted or closed.
#
class EmailedTickets
def initialize(vcs, tickets)
@vcs = vcs
@tickets = tickets
end
def notify(issue, message)
@tickets.notify(issue, message)
end
def submit(puzzle)
done = @tickets.submit(puzzle)
issue_link = @vcs.issue_link(done[:number])
file_link = @vcs.file_link(puzzle.xpath('file')[0].text)
Mail.new do
from '0pdd '
to 'admin@0pdd.com'
subject "#{issue_link} opened"
text_part do
content_type 'text/plain; charset=UTF-8'
body "Hey,\n\n\
Issue #{done[:href]} opened.\n\n\
ID: #{puzzle.xpath('id')[0].text}\n\
File: #{puzzle.xpath('file')[0].text}\n\
Lines: #{puzzle.xpath('lines')[0].text}\n\
Here: #{file_link}\
##{puzzle.xpath('lines')[0].text.gsub(/(\d+)/, 'L\1')}\n\
Author: #{puzzle.xpath('author')[0].text}\n\
Time: #{puzzle.xpath('time')[0].text}\n\
Estimate: #{puzzle.xpath('estimate')[0].text} minutes\n\
Role: #{puzzle.xpath('role')[0].text}\n\n\
Body: #{puzzle.xpath('body')[0].text}\n\n\
Thanks,\n\
0pdd"
end
end.deliver!
done
end
def close(puzzle)
done = @tickets.close(puzzle)
if done
issue_number = puzzle.xpath('issue')[0].text
issue_link = @vcs.issue_link(issue_number)
Mail.new do
from '0pdd '
to 'admin@0pdd.com'
subject "#{issue_link} closed"
text_part do
content_type 'text/plain; charset=UTF-8'
body "Hey,\n\n\
Issue #{issue_link} closed.\n\n\
Thanks,\n\
0pdd"
end
end.deliver!
end
done
end
end
================================================
FILE: objects/tickets/logged_tickets.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'cgi'
require_relative '../truncated'
require_relative '../user_error'
#
# Tickets that are logged.
#
class LoggedTickets
def initialize(vcs, log, tickets)
@vcs = vcs
@log = log
@tickets = tickets
end
def notify(issue, message)
@tickets.notify(issue, message)
end
def submit(puzzle)
tag = "#{puzzle.xpath('id')[0].text}/submit"
if @log.exists(tag)
raise UserError, "Tag \"#{tag}\" already exists, won't submit again. \
This situation most probably means that \
this puzzle was already seen in the code and \
you're trying to create it again. We would recommend you to re-phrase \
the text of the puzzle and push again. If this doesn't work, please let us know \
in GitHub: https://github.com/yegor256/0pdd/issues. More details here: \
https://www.0pdd.com/log-item?repo=#{CGI.escape(@vcs.repo.name)}&tag=#{CGI.escape(tag)}&vcs=#{@vcs.name.downcase} ."
end
done = @tickets.submit(puzzle)
@log.put(
tag,
"#{puzzle.xpath('id')[0].text} submitted in issue ##{done[:number]}: \
\"#{Truncated.new(puzzle.xpath('body')[0].text, 100)}\" \
at #{puzzle.xpath('file')[0].text}; #{puzzle.xpath('lines')[0].text}"
)
done
end
def close(puzzle)
done = @tickets.close(puzzle)
if done
tag = "#{puzzle.xpath('id')[0].text}/closed"
if @log.exists(tag)
raise UserError, "Tag \"#{tag}\" already exists, won't close again. \
This is a rare and rather unusual bug. Please report it to us: \
https://github.com/yegor256/0pdd/issues. More details here: \
https://www.0pdd.com/log-item?repo=#{CGI.escape(@vcs.repo.name)}&tag=#{CGI.escape(tag)}&vcs=#{@vcs.name.downcase} ."
end
@log.put(
tag,
"#{puzzle.xpath('id')[0].text} closed in issue \
##{puzzle.xpath('issue')[0].text}"
)
end
done
end
end
================================================
FILE: objects/tickets/milestone_tickets.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Tickets that inherit milestones.
#
class MilestoneTickets
def initialize(vcs, tickets)
@vcs = vcs
@tickets = tickets
end
def notify(issue, message)
@tickets.notify(issue, message)
end
def submit(puzzle)
submitted = @tickets.submit(puzzle)
config = @vcs.repo.config
if config['tickets']&.include?('inherit-milestone') &&
puzzle.xpath('ticket')[0].text =~ /[0-9]+/
num = puzzle.xpath('ticket')[0].text.to_i
parent = @vcs.issue(num)
unless parent.nil? || parent[:milestone].nil?
begin
@vcs.update_issue(
num,
milestone: parent[:milestone][:number]
)
unless config.dig('alerts', 'suppress')
&.include?('on-inherited-milestone')
@vcs.add_comment(
submitted[:number],
"This puzzle inherited milestone \
`#{parent[:milestone][:title]}` from issue ##{num}."
)
end
rescue Octokit::Error, Gitlab::Error::Error, JIRA::Error::Error => e
@vcs.add_comment(
submitted[:number],
"For some reason I wasn't able to set milestone \
`#{parent[:milestone][:title]}`, inherited from `#{num}`, \
to this issue. Please, \
[submit a ticket](https://github.com/yegor256/0pdd/issues/new) \
to us with the text you see below:\
\n\n```#{e.class.name}\n#{e.message}\n#{e.backtrace.join("\n")}\n```"
)
end
end
end
submitted
end
def close(puzzle)
@tickets.close(puzzle)
end
end
================================================
FILE: objects/tickets/sentry_tickets.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'mail'
require 'sentry-ruby'
require_relative '../user_error'
require_relative '../truncated'
#
# Tickets that report to Sentry.
#
class SentryTickets
def initialize(tickets)
@tickets = tickets
end
def notify(issue, message)
@tickets.notify(issue, message)
rescue UserError => e
puts e.message
rescue Exception => e
Sentry.capture_exception(e)
email(e)
raise e
end
def submit(puzzle)
@tickets.submit(puzzle)
rescue UserError => e
puts e.message
nil
rescue Exception => e
Sentry.capture_exception(e)
email(e)
raise e
end
def close(puzzle)
@tickets.close(puzzle)
rescue UserError => e
puts e.message
true
rescue Exception => e
Sentry.capture_exception(e)
email(e)
raise e
end
private
def email(e)
mail = Mail.new do
from '0pdd '
to 'admin@0pdd.com'
subject Truncated.new(e.message).to_s
text_part do
content_type 'text/plain; charset=UTF-8'
body "Hi,\n\n\
#{e.message}\n\n
#{e.backtrace.join("\n")}\n\n
Thanks,\n\
0pdd"
end
html_part do
content_type 'text/html; charset=UTF-8'
body "
Hi,
#{e.message}\n\n#{e.backtrace.join("\n")}
"
end
end
mail.deliver!
end
end
================================================
FILE: objects/tickets/tagged_tickets.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Tagged tickets.
#
class TaggedTickets
def initialize(vcs, tickets)
@vcs = vcs
@tickets = tickets
end
def notify(issue, message)
@tickets.notify(issue, message)
end
def submit(puzzle)
issue = @tickets.submit(puzzle)
issue_id = issue[:number]
yaml = @vcs.repo.config
if yaml['tags'].is_a?(Array)
tags = yaml['tags'].map { |x| x.strip.downcase }
labels = @vcs.labels
.map { |json| json[:name] }
.map { |x| x.strip.downcase }
needed = tags - labels
begin
needed.each { |t| @vcs.add_label(t, 'F74219') }
@vcs.add_labels_to_an_issue(issue_id, tags)
rescue Octokit::Error, Gitlab::Error::Error, JIRA::Error::Error => e
@vcs.add_comment(
issue_id,
"I can't create #{@vcs.name} labels `#{needed.join('`, `')}`. \
Most likely I don't have necessary permissions to `#{@vcs.repo.name}` repository. \
Please, make sure @0pdd user is in the \
[list of collaborators](#{@vcs.collaborators_link}):\
\n\n```#{e.class.name}\n#{e.message}\n#{e.backtrace.join("\n")}\n```"
)
rescue Octokit::NotFound, Gitlab::Error::NotFound, JIRA::Error::NotFound => e
@vcs.add_comment(
issue_id,
"For some reason I wasn't able to add #{@vcs.name} labels \
`#{needed.join('`, `')}` to this issue \
(required=`#{tags.join('`, `')}`; existing=`#{labels.join('`, `')}`). \
Please, [submit a ticket](https://github.com/yegor256/0pdd/issues/new) \
to us with the text you see below:\
\n\n```#{e.class.name}\n#{e.message}\n#{e.backtrace.join("\n")}\n```"
)
end
end
issue
end
def close(puzzle)
@tickets.close(puzzle)
end
end
================================================
FILE: objects/tickets/tickets.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'haml'
require_relative '../truncated'
require_relative '../maybe_text'
#
# One ticket.
#
class Tickets
def initialize(vcs)
@vcs = vcs
end
def notify(issue, message)
@vcs.add_comment(
issue,
"@#{@vcs.issue(issue)[:author][:username]} #{message}"
)
rescue Octokit::NotFound, Gitlab::NotFound, JIRA::NotFound => e
puts "The issue most probably is not found, can't comment: #{e.message}"
end
def submit(puzzle)
data = { title: title(puzzle), description: body(puzzle) }
issue = @vcs.create_issue(data)
unless users.empty?
@vcs.add_comment(
issue[:number],
(users + ['please pay attention to this new issue.']).join(' ')
)
end
{ number: issue[:number], href: issue[:html_url] }
end
def close(puzzle)
issue = puzzle.xpath('issue')[0].text
return true if @vcs.issue(issue)[:state] == 'closed'
@vcs.close_issue(issue)
@vcs.add_comment(
issue,
[
"The puzzle `#{puzzle.xpath('id')[0].text}` has disappeared",
" from the source code, that's why I closed this issue.",
(users.empty? ? '' : " //cc #{users.join(' ')}")
].join
)
true
end
private
def users
yaml = @vcs.repo.config
if !yaml.nil? && yaml['alerts'] && yaml['alerts'][@vcs.name.downcase]
yaml['alerts'][@vcs.name.downcase]
.map { |x| x.strip.downcase }
.map { |n| n.gsub(/[^0-9a-zA-Z-]+/, '') }
.map { |n| n[0..64] }
.map { |n| "@#{n}" }
else
[]
end
end
def title(puzzle)
yaml = @vcs.repo.config
format = []
format += yaml['format'].map { |x| x.strip.downcase } if !yaml.nil? && yaml['format'].is_a?(Array)
len = format.find { |i| i =~ /title-length=\d+/ }
Truncated.new(
if format.include?('short-title')
puzzle.xpath('body')[0].text
else
subject = File.basename(puzzle.xpath('file')[0].text)
start, stop = puzzle.xpath('lines')[0].text.split('-')
[
subject,
':',
(start == stop ? start : "#{start}-#{stop}"),
": #{puzzle.xpath('body')[0].text}"
].join
end,
[[len ? len.gsub(/^title-length=/, '').to_i : 60, 30].max, 255].min
).to_s
end
def body(puzzle)
file = puzzle.xpath('file')[0].text
start, stop = puzzle.xpath('lines')[0].text.split('-')
sha = @vcs.repo.head_commit_hash || vcs.repo.master
url = @vcs.puzzle_link_for_commit(sha, file, start, stop)
template = File.read(
File.join(File.dirname(__FILE__), "../templates/#{@vcs.name.downcase}_tickets_body.haml")
)
Haml::Engine.new(template).render(
Object.new, url: url, puzzle: puzzle
)
end
end
================================================
FILE: objects/truncated.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# Truncated text.
#
class Truncated
def initialize(text, max = 40, tail = '...')
@text = text
@max = max
@tail = tail
end
def to_s
clean = @text.gsub(/\s+/, ' ').strip
if @max < clean.length
limit = @max - @tail.length
stop = clean.rindex(' ', limit) || 0
"#{clean[0...stop]}#{@tail}"
else
clean
end
end
end
================================================
FILE: objects/user_error.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
#
# User Error
#
class UserError < StandardError
end
================================================
FILE: objects/vcs/github.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'octokit'
require_relative '../git_repo'
#
# Github VCS
#
class GithubRepo
attr_reader :repo, :name
def initialize(client, json, config = {})
@name = 'github'
@client = client
@config = config
@json = json
@repo = git_repo(json, config)
end
# Check whether this repository exists in GitHub and we have
# access to it. Well, the actual access permissions are not checked
# here, but we only try to read properties of the repo. If such a HTTP
# request fails, the method returns FALSE.
def exists?
@client.repository(@repo.name)
true
rescue Octokit::NotFound => e
puts "Repository #{@repo.name} is not available: #{e.message}"
false
end
# Read information about one issue in GitHub and return it
# as a map.
def issue(issue_id)
hash = @client.issue(@repo.name, issue_id)
id = hash[:user][:id] if hash[:user]
username = hash[:user][:login] if hash[:user]
{
state: hash[:state],
author: {
id: id,
username: username
},
milestone: hash[:milestone]
}
end
# @todo #312:30min Currently, if 0pdd fails to close an issue it causes all other downstream execution to be skipped
# therefore leaving the job in a non deterministic state. Catch and track the error here to
# prevent this from happening. Also applies to `add_comment(...)`
def close_issue(issue_id)
@client.close_issue(@repo.name, issue_id)
end
def create_issue(data)
fields = %i[title description]
options = data.reject { |k| fields.include? k }
@client.create_issue(
@repo.name,
data[:title],
data[:description],
options
)
end
def update_issue(issue_id, data)
@client.update_issue(@repo.name, issue_id, data)
end
def labels
@client.labels(@repo.name)
end
def add_label(label, color)
@client.add_label(@repo.name, label, color)
end
def add_labels_to_an_issue(issue_id, labels)
@client.add_labels_to_an_issue(@repo.name, issue_id, labels)
end
def add_comment(issue_id, comment)
@client.add_comment(@repo.name, issue_id, comment)
end
def create_commit_comment(sha, comment)
@client.create_commit_comment(@repo.name, sha, comment)
end
def list_commits
@client.commits(@repo.name)
end
def user(username)
@client.user(username)
end
def star
@client.star(@repo.name)
end
def repository_link
"https://github.com/#{@repo.name}"
end
def collaborators_link
"https://github.com/#{@repo.name}/settings/collaboration"
end
def file_link(file)
"https://github.com/#{@repo.name}/blob/#{@repo.master}/#{file})"
end
def puzzle_link_for_commit(sha, file, start, stop)
"https://github.com/#{@repo.name}/blob/#{sha}/#{file}#L#{start}-L#{stop}"
end
def issue_link(issue_id)
"https://github.com/#{@repo.name}/issues/#{issue_id}"
end
private
def git_repo(json, config)
uri = json['repository']['ssh_url'] || json['repository']['url']
target = json['ref']
name = json['repository']['full_name']
default_branch = json['repository']['master_branch']
head_commit_hash = json['head_commit'] ? json['head_commit']['id'] : ''
GitRepo.new(
uri: uri,
name: name,
id_rsa: config['id_rsa'],
target: target,
master: default_branch,
head_commit_hash: head_commit_hash
)
end
end
================================================
FILE: objects/vcs/gitlab.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'gitlab'
require_relative '../git_repo'
require_relative '../clients/gitlab'
#
# Gitlab repo
# API: https://github.com/NARKOZ/gitlab
#
class GitlabRepo
attr_reader :repo, :name
def initialize(client, json, config = {})
@name = 'github'
@client = client
@config = config
@json = json
@repo = git_repo(json, config)
end
def issue(issue_id)
hash = JSON.parse(
@client.issue(@repo.name, issue_id).to_hash.to_json,
symbolize_names: true
)
number, title = hash[:milestone].values_at(:id, :title) if hash[:milestone]
{
state: hash[:state],
author: hash[:author],
milestone: {
number: number,
title: title
}
}
rescue Gitlab::Error::NotFound => e
raise "The issue most probably is not found, can' comment: #{e.message}"
end
def close_issue(issue_id)
@client.close_issue(@repo.name, issue_id)
rescue Gitlab::Error::NotFound => e
raise "The issue most probably is not found, can't close: #{e.message}"
end
def create_issue(data)
options = data.reject { |k| k == :title }
hash = JSON.parse(
@client.create_issue(@repo.name, data[:title], options).to_hash.to_json,
symbolize_names: true
)
{ number: hash[:iid], html_url: hash[:web_url] }
end
def update_issue(issue_id, data)
@client.edit_issue(@repo.name, issue_id, data)
end
def labels
result = []
@client.labels(@repo.name).each_page do |page|
page.each do |label|
result << JSON.parse(
label.to_hash.to_json,
symbolize_names: true
)
end
end
result
end
def add_label(label, color)
@client.add_label(@repo.name, label, color)
end
def add_labels_to_an_issue(issue_id, labels)
options = { labels: labels }
@client.edit_issue(@repo.name, issue_id, options)
end
def add_comment(issue_id, comment)
@client.create_issue_note(@repo.name, issue_id, comment)
rescue Gitlab::Error::NotFound => e
raise "The issue most probably is not found, can't comment: #{e.message}"
end
def create_commit_comment(sha, comment)
hash = JSON.parse(
@client.create_commit_comment(@repo.name, sha, comment).to_hash.to_json,
symbolize_names: true
)
hash[:html_url] = "https://gitlab.com/#{@repo.name}/commit/#{sha}"
hash
end
def list_commits
commits = []
@client.commits(@repo.name).each_page do |page|
page.each do |commit|
commits << { sha: commit.id, html_url: commit.web_url }
end
end
commits
end
def user(username)
hash = JSON.parse(
@client.user(username).to_hash.to_json,
symbolize_names: true
)
hash[:email] = hash[:public_email]
hash
end
def star
@client.star_project(@repo.name)
end
def exists?
hash = JSON.parse(
@client.project(@repo.name).to_hash.to_json,
symbolize_names: true
)
hash[:private] = hash[:visibility] == 'private'
true
rescue Gitlab::Error::NotFound => e
puts "Repository #{@repo.name} is not available: #{e.message}"
false
rescue Gitlab::Error::Forbidden => e
puts "Repository #{@repo.name} is not accessible: #{e.message}"
false
end
def repository_link
"https://gitlab.com/#{@repo.name}"
end
def collaborators_link
"https://gitlab.com/#{@repo.name}/project_members"
end
def file_link(file)
"https://gitlab.com/#{@repo.name}/blob/#{@repo.master}/#{file})"
end
def puzzle_link_for_commit(sha, file, start, stop)
"https://gitlab.com/#{@repo.name}/blob/#{sha}/#{file}#L#{start}-L#{stop}"
end
def issue_link(issue_id)
"https://gitlab.com/#{@repo.name}/issues/#{issue_id}"
end
private
def git_repo(json, config)
uri = json['project']['url']
name = json['project']['path_with_namespace']
target = json['ref']
default_branch = json['project']['default_branch']
head_commit_hash = json['checkout_sha']
GitRepo.new(
uri: uri,
name: name,
target: target,
id_rsa: config['id_rsa'],
master: default_branch,
head_commit_hash: head_commit_hash
)
end
end
================================================
FILE: objects/vcs/jira.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'jira-ruby'
require_relative '../git_repo'
#
# Jira VCS
#
class JiraRepo
attr_reader :repo, :name
def initialize(client, json, config = {})
@name = 'JIRA'
@client = client
@config = config
@json = json
@repo = git_repo(json, config)
end
def issue(issue_id)
@client.Issue.find(issue_id)
end
def close_issue(issue_id)
issue = @client.Issue.find(issue_id)
issue.save(
'fields' => {
'summary' => data[:description],
'project' => { 'id' => data[:repo] },
'issuetype' => { 'id' => '3' },
'status' => 'closed'
}
)
issue.fetch
end
def create_issue(data)
issue = @client.Issue.build
issue.save(
'fields' => {
'summary' => data[:description],
'project' => { 'id' => data[:repo] },
'issuetype' => { 'id' => '3' }
}
)
issue.fetch
end
def update_issue(issue_id, data)
issue = @client.Issue.find(issue_id)
issue.save(
'fields' => {
'summary' => data[:description],
'project' => { 'id' => data[:repo] },
'issuetype' => { 'id' => '3' }
}
)
issue.fetch
end
def exists?
@client.Project.find(@repo.name)
true
rescue JIRA::NotFound => e
puts "Repository #{@repo.name} is not available: #{e.message}"
false
end
def repository_link
"https://your-domain.atlassian.net/rest/api/3/project#{@repo.name}"
end
private
def git_repo(json, config)
uri = json['repository']['ssh_url'] || json['repository']['url']
name = json['repository']['full_name']
default_branch = json['repository']['master_branch']
head_commit_hash = json['head_commit']['id']
GitRepo.new(
uri: uri,
name: name,
id_rsa: config['id_rsa'],
master: default_branch,
head_commit_hash: head_commit_hash
)
end
end
================================================
FILE: renovate.json
================================================
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
]
}
================================================
FILE: test/fake_github.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
class FakeGithub
attr_reader :name, :repo
def initialize(options = {})
@name = 'GITHUB'
@memberships = options[:memberships] || [
{
'state' => 'pending',
'organization' => {
'login' => 'github'
}
}, {
'state' => 'pending',
'organization' => {
'login' => 'zerocracy'
}
}
]
@invitations = options[:invitations] || [
{
'id' => 1001,
'repository' => {
'name' => 'yegor256/0pdd'
}
}, {
'id' => 1023,
'repository' => {
'name' => 'yegor256/sixnines'
}
}
]
@repositories = options[:repositories] || []
@repo = options[:repo]
end
def rate_limit
limit = Object.new
def limit.remaining
4096
end
limit
end
def update_organization_membership(org, options = {})
return unless options['state']
@memberships.find do |m|
m['organization']['login'] == org
end['state'] = options['state']
end
def organization_memberships(options = {})
if options['state']
@memberships.find_all { |m| m['state'] == options['state'] }
else
@memberships
end
end
def user_repository_invitations(_options = {})
@invitations
end
def accept_repository_invitation(id, _options = {})
invitation = @invitations.find { |i| i['id'] == id }
return false if invitation.nil?
@repositories.push(invitation['repository']['name'])
true
end
def repositories(user = nil, _options = {})
@repositories unless user
end
def issue(_)
{
state: 'open',
author: {
id: '1',
username: 'yegor256'
},
milestone: {
number: 1,
title: 'v0.1'
}
}
end
def close_issue(_); end
def create_issue(_)
{
number: 1,
html_url: 'url'
}
end
def update_issue(_, _); end
def labels
[
{
id: ``,
name: 'Dev',
color: '#ff00ff'
}
]
end
def add_label(_, _); end
def add_labels_to_an_issue(_, _); end
def add_comment(_, _); end
def create_commit_comment(_, _, _)
{
html_url: 'url'
}
end
def list_commits
[
{
sha: '123456',
html_url: 'url'
}
]
end
def user(_)
{
name: 'foobar',
email: 'foobar@example.com'
}
end
def star; end
def repository(_ = nil)
{
private: false
}
end
def repository_link
"https://github.com/#{@repo.name}"
end
def collaborators_link
"https://github.com/#{@repo.name}/settings/collaboration"
end
def file_link(file)
"https://github.com/#{@repo.name}/blob/#{@repo.master}/#{file})"
end
def puzzle_link_for_commit(sha, file, start, stop)
"https://github.com/#{@repo.name}/blob/#{sha}/#{file}#L#{start}-L#{stop}"
end
def issue_link(issue_id)
"https://github.com/#{@repo.name}/issues/#{issue_id}"
end
private
def git_repo
# Output:
# repo -> GitRepo
raise NotImplementedError, 'You must implement this method'
end
end
================================================
FILE: test/fake_gitlab.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
class FakeGitlab
attr_reader :name, :repo
def initialize(options = {})
@name = 'GITLAB'
@repositories = options[:repositories] || []
@projects = options[:projects] || []
@repo = options[:repo]
end
def repositories(user = nil, _options = {})
@repositories unless user
end
def issue(_)
{
state: 'open',
author: {
id: '1',
username: 'yegor256'
},
milestone: {
number: 1,
title: 'v0.1'
}
}
end
def close_issue(_); end
def create_issue(_)
{
number: 1,
html_url: 'url'
}
end
def update_issue(_, _); end
def labels
[
{
id: ``,
name: 'Dev',
color: '#ff00ff'
}
]
end
def add_label(_, _); end
def add_labels_to_an_issue(_, _); end
def add_comment(_, _); end
def create_commit_comment(_, _)
{
html_url: 'url'
}
end
def list_commits
[
{
sha: '123456',
html_url: 'url'
}
]
end
def user(_)
{
name: 'foobar',
email: 'foobar@example.com'
}
end
def star; end
def repository(_ = nil)
{
private: false
}
end
def project(_ = nil)
{
private: false
}
end
def repository_link
"https://gitlab.com/#{@repo.name}"
end
def collaborators_link
"https://gitlab.com/#{@repo.name}/project_members"
end
def file_link(file)
"https://gitlab.com/#{@repo.name}/blob/#{@repo.master}/#{file})"
end
def puzzle_link_for_commit(sha, file, start, stop)
"https://gitlab.com/#{@repo.name}/blob/#{sha}/#{file}#L#{start}-L#{stop}"
end
def issue_link(issue_id)
"https://gitlab.com/#{@repo.name}/issues/#{issue_id}"
end
private
def git_repo
# Output:
# repo -> GitRepo
raise NotImplementedError, 'You must implement this method'
end
end
================================================
FILE: test/fake_log.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
class FakeLog
attr_reader :tag, :title
def exists(_)
false
end
def put(tag, text)
@title = text
@tag = tag
end
def get(_tag); end
def delete(_time, _tag); end
def list(_since = Time.now.to_i)
[]
end
end
================================================
FILE: test/fake_repo.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'nokogiri'
require 'tempfile'
class FakeRepo
attr_reader :name, :config
def initialize(options = {})
@name = options[:name] || 'GITHUB'
@config = options[:config] || {}
end
def lock
Tempfile.new('0pdd-lock')
end
def xml
Nokogiri::XML('')
end
def push
# nothing here
end
end
================================================
FILE: test/fake_storage.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'nokogiri'
require 'tempfile'
class FakeStorage
def initialize(
dir = Dir.mktmpdir,
xml = ''
)
@file = File.join(dir, 'storage.xml')
save(xml)
end
def load
Nokogiri.XML(File.read(@file))
end
def save(xml)
File.write(@file, xml.to_s)
end
end
================================================
FILE: test/fake_tickets.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
class FakeTickets
attr_reader :submitted, :closed
def initialize
@submitted = []
@closed = []
end
def submit(puzzle)
@submitted << puzzle.xpath('id')[0].text
{ number: '123', href: 'http://0pdd.com' }
end
def close(puzzle)
@closed << puzzle.xpath('id')[0].text
true
end
end
================================================
FILE: test/test_0pdd.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT
require 'rack/test'
require_relative 'test__helper'
require_relative '../0pdd'
class AppTest < Minitest::Test
include Rack::Test::Methods
def app
Sinatra::Application
end
def test_renders_version
get('/version')
assert_predicate(last_response, :ok?)
end
def test_robots_txt
get('/robots.txt')
assert_predicate(last_response, :ok?)
end
def test_it_renders_home_page
get('/')
assert_predicate(last_response, :ok?)
assert_includes(last_response.body, '0pdd')
end
def test_renders_some_pages
[
'/',
'/robots.txt',
'/version',
'/puzzles.xsd',
'/logout',
'/css/main.css'
].each do |page|
get(page)
assert_operator(last_response.status, :<, 400, "Failed to render #{page}")
end
end
def test_it_renders_puzzles_xsd
get('/puzzles.xsd')
assert_predicate(last_response, :ok?)
assert_includes(last_response.body, ' 'application/json',
'HTTP_USER_AGENT' => 'GitHub-Hookshot',
'HTTP_X_GITHUB_EVENT' => 'push'
}
post(
'/hook/github',
['{"head_commit":{"id":"-"},',
'"repository":{"url":"localhost",',
'"full_name":"yegor256-one/com.github.0pdd-test"},',
'"ref":"refs/heads/master"}'].join,
headers
)
assert_predicate(last_response, :ok?)
assert_includes(last_response.body, 'Thanks')
end
def test_it_ignores_push_from_github_to_not_master
headers = {
'CONTENT_TYPE' => 'application/json',
'HTTP_USER_AGENT' => 'GitHub-Hookshot',
'HTTP_X_GITHUB_EVENT' => 'push'
}
post(
'/hook/github',
['{"head_commit":{"id":"-"},',
'"repository":{"url":"localhost",',
'"full_name":"yegor256-one/com.github.0pdd-test"},',
'"ref":"refs/heads/main"}'].join,
headers
)
assert_predicate(last_response, :ok?)
assert_includes(last_response.body, 'Thanks')
assert_includes(last_response.body, 'nothing is done')
end
def test_it_accepts_push_from_github_to_not_default_master
headers = {
'CONTENT_TYPE' => 'application/json',
'HTTP_USER_AGENT' => 'GitHub-Hookshot',
'HTTP_X_GITHUB_EVENT' => 'push'
}
post(
'/hook/github',
['{"head_commit":{"id":"-"},',
'"repository":{"url":"localhost",',
'"master_branch": "main",',
'"full_name":"yegor256-one/com.github.0pdd-test"},',
'"ref":"refs/heads/main"}'].join,
headers
)
assert_predicate(last_response, :ok?)
assert_includes(last_response.body, 'Thanks')
refute_includes(last_response.body, 'nothing is done')
end
def test_it_ignore_push_from_github_to_not_default_master
headers = {
'CONTENT_TYPE' => 'application/json',
'HTTP_USER_AGENT' => 'GitHub-Hookshot',
'HTTP_X_GITHUB_EVENT' => 'push'
}
post(
'/hook/github',
['{"head_commit":{"id":"-"},',
'"repository":{"url":"localhost",',
'"master_branch": "main",',
'"full_name":"yegor256-one/com.github.0pdd-test"},',
'"ref":"refs/heads/master"}'].join,
headers
)
assert_predicate(last_response, :ok?)
assert_includes(last_response.body, 'Thanks')
assert_includes(last_response.body, 'nothing is done')
end
def test_it_understands_push_from_gitlab
headers = {
'CONTENT_TYPE' => 'application/json',
'HTTP_USER_AGENT' => 'GitLab 16.6',
'HTTP_X_GITLAB_EVENT' => 'Push Hook'
}
post(
'/hook/gitlab',
['{"checkout_sha": "da1560886d4",',
'"project":{"url":"localhost",',
'"path_with_namespace":"yegor256-one/com.github.0pdd-test"},',
'"ref":"refs/heads/master"}'].join,
headers
)
assert_predicate(last_response, :ok?)
assert_includes(last_response.body, 'Thanks')
end
def test_it_ignores_push_from_gitlab_to_not_master
headers = {
'CONTENT_TYPE' => 'application/json',
'HTTP_USER_AGENT' => 'GitLab 16.6',
'HTTP_X_GITLAB_EVENT' => 'Push Hook'
}
post(
'/hook/gitlab',
['{"checkout_sha": "da1560886d4",',
'"project":{"url":"localhost",',
'"path_with_namespace":"yegor256-one/com.github.0pdd-test"},',
'"ref":"refs/heads/main"}'].join,
headers
)
assert_predicate(last_response, :ok?)
assert_includes(last_response.body, 'Thanks')
assert_includes(last_response.body, 'nothing is done')
end
def test_it_accepts_push_from_gitlab_to_not_default_master
headers = {
'CONTENT_TYPE' => 'application/json',
'HTTP_USER_AGENT' => 'GitLab 16.6',
'HTTP_X_GITLAB_EVENT' => 'Push Hook'
}
post(
'/hook/gitlab',
['{"checkout_sha": "da1560886d4",',
'"project":{"url":"localhost",',
'"default_branch": "main",',
'"path_with_namespace":"yegor256-one/com.github.0pdd-test"},',
'"ref":"refs/heads/main"}'].join,
headers
)
assert_predicate(last_response, :ok?)
assert(last_response.body.start_with?('Thanks'))
refute_includes(last_response.body, 'nothing is done')
end
def test_it_ignores_push_from_gitlab_to_not_default_master
headers = {
'CONTENT_TYPE' => 'application/json',
'HTTP_USER_AGENT' => 'GitLab 16.6',
'HTTP_X_GITLAB_EVENT' => 'Push Hook'
}
post(
'/hook/gitlab',
['{"checkout_sha": "da1560886d4",',
'"project":{"url":"localhost",',
'"default_branch": "main",',
'"path_with_namespace":"yegor256-one/com.github.0pdd-test"},',
'"ref":"refs/heads/master"}'].join,
headers
)
assert_predicate(last_response, :ok?)
assert_includes(last_response.body, 'Thanks')
assert_includes(last_response.body, 'nothing is done')
end
def test_renders_html_puzzles
get('/p?name=yegor256/pdd')
assert_predicate(last_response, :ok?)
html = last_response.body
assert(
html.include?(''),
"broken HTML: #{html}"
)
end
def test_snapshots_unavailable_repo
get('/snapshot?name=yegor256/0pdd_foobar_unavailable')
assert_equal(400, last_response.status)
end
def test_renders_svg_puzzles
get('/svg?name=yegor256/pdd')
assert_predicate(last_response, :ok?)
svg = last_response.body
File.write('/tmp/0pdd-button.svg', svg)
assert_includes(
svg, '