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 [![EO principles respected here](https://www.elegantobjects.org/badge.svg)](https://www.elegantobjects.org) [![DevOps By Rultor.com](https://www.rultor.com/b/yegor256/0pdd)](https://www.rultor.com/p/yegor256/0pdd) [![We recommend RubyMine](https://www.elegantobjects.org/rubymine.svg)](https://www.jetbrains.com/ruby/) [![rake](https://github.com/yegor256/0pdd/actions/workflows/rake.yml/badge.svg)](https://github.com/yegor256/0pdd/actions/workflows/rake.yml) [![Availability at SixNines](https://www.sixnines.io/b/574a)](https://www.sixnines.io/h/574a) [![Webhook via ReHTTP](https://www.rehttp.net/b?u=http%3A%2F%2Fwww.0pdd.com%2Fhook%2Fgithub)](https://www.rehttp.net/i?u=http%3A%2F%2Fwww.0pdd.com%2Fhook%2Fgithub) [![PDD status](https://www.0pdd.com/svg?name=yegor256/0pdd)](https://www.0pdd.com/p?name=yegor256/0pdd) [![Maintainability](https://api.codeclimate.com/v1/badges/7462387124cf5f9b8ef8/maintainability)](https://codeclimate.com/github/yegor256/0pdd/maintainability) [![Test Coverage](https://img.shields.io/codecov/c/github/yegor256/0pdd.svg)](https://codecov.io/github/yegor256/0pdd?branch=master) [![Hits-of-Code](https://hitsofcode.com/github/yegor256/0pdd)](https://hitsofcode.com/view/github/yegor256/0pdd) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/d23061346143451db3abedca5ad9cbf2)](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 [![PDD status](https://www.0pdd.com/svg?name=yegor256/0pdd)](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 ================================================ true false unknown ================================================ FILE: assets/xsl/puzzles.xsl ================================================ <xsl:value-of select="$project"/>

alive, total.

--

Full log of recent events.

Download XML ( Kb ); see snapshot .

Project " " updated by 0pdd v on .

color:gray; : min
================================================ FILE: assets/xsl/svg.xsl ================================================ 86 0pdd 0pdd ================================================ FILE: assets/xsl/to-close.xsl ================================================ ================================================ FILE: assets/xsl/to-submit.xsl ================================================ ================================================ FILE: config.ru ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require './0pdd' $stdout.sync = true run Sinatra::Application ================================================ FILE: cucumber.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT --- default: --format pretty travis: --format progress html_report: --format progress --format html --out=features_report.html ================================================ FILE: deploy.sh ================================================ #!/usr/bin/env bash # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT set -e -o pipefail cd "$(dirname "$0")" bundle update sed -i -s 's|Gemfile.lock||g' .gitignore cp /code/home/assets/0pdd/config.yml . git add config.yml git add Gemfile.lock git add .gitignore git commit --no-verify -m 'config.yml for heroku' trap 'git reset HEAD~1 && rm config.yml && git checkout -- .gitignore' EXIT git push heroku master -f ================================================ FILE: dynamodb-local/config/dynamo.yml ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT --- port: ${dynamo.port} key: ${dynamo.key} secret: ${dynamo.secret} ================================================ FILE: dynamodb-local/pom.xml ================================================ 4.0.0 com.0pdd dynamodb-local 1.0-SNAPSHOT pom dynamodb-local AAAAABBBBBAAAAABBBBB ABCDABCDABCDABCDABCDABCDABCDABCDABCDABCD maven-dependency-plugin unpack-dynamodb-local unpack com.jcabi DynamoDBLocal 2023-05-26 zip ${project.build.directory}/dynamodb-dist false org.codehaus.mojo build-helper-maven-plugin 3.6.1 reserver-dynamodb-port reserve-network-port dynamo.port maven-resources-plugin 3.5.0 copy-resources pre-integration-test copy-resources ${project.build.directory} ${basedir}/config true com.jcabi jcabi-dynamodb-maven-plugin 0.10.1 dynamodb-integration-test start create-tables wait ${dynamo.port} ${project.build.directory}/dynamodb-dist ${dynamo.key} ${dynamo.secret} -inMemory ${basedir}/tables/0pdd-events.json
================================================ FILE: dynamodb-local/tables/0pdd-events.json ================================================ { "AttributeDefinitions": [ { "AttributeName": "repo", "AttributeType": "S" }, { "AttributeName": "time", "AttributeType": "N" }, { "AttributeName": "tag", "AttributeType": "S" } ], "GlobalSecondaryIndexes": [ { "IndexName": "tags", "KeySchema": [ { "AttributeName": "repo", "KeyType": "HASH" }, { "AttributeName": "tag", "KeyType": "RANGE" } ], "Projection": { "ProjectionType": "ALL" }, "ProvisionedThroughput": { "ReadCapacityUnits": "1", "WriteCapacityUnits": "1" } } ], "KeySchema": [ { "AttributeName": "repo", "KeyType": "HASH" }, { "AttributeName": "time", "KeyType": "RANGE" } ], "ProvisionedThroughput": { "ReadCapacityUnits": "1", "WriteCapacityUnits": "1" }, "TableName": "0pdd-events" } ================================================ FILE: features/step_definitions/steps.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'tmpdir' require 'English' Before do @cwd = Dir.pwd @dir = Dir.mktmpdir('test') FileUtils.mkdir_p(@dir) Dir.chdir(@dir) end After do Dir.chdir(@cwd) FileUtils.rm_rf(@dir) end ================================================ FILE: model/README.md ================================================ Puzzle Ranking (Linear ML Model) ### Internals The ML model is a linear model with PSO optimizer. The optimizer is used to train the model on puzzle data, the weights are stored and used to predict future puzzles. Because of the time required, training is a non-blocking process, and puzzle prioritization uses a naive ranking approach based on puzzle estimate. Subsequent events use the linear model for prioritization. The linear model is the external API for the model. It has one method `predict(...)` which accepts an array of puzzles in xml. The output of this model is an array of positional index of the input puzzles: ```ruby # usage rank = LinearModel.new(repo_name, storage).predict(puzzles) # repo_name -> name of repository # storage -> storage object (with defined interface) # puzzles -> array of xml puzzles. # # rank -> array of positional index of ranked puzzles ``` ### Integration This diagram shows how this model can be integrated into 0pdd workflow: ![integration.svg](../doc/integration.svg) ================================================ FILE: model/fake_weights_storage.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT # # FakeWeightsStorage # class FakeWeightsStorage def initialize( repo, dir = Dir.mktmpdir ) @file = File.join(dir, "#{repo}.marshal") end def load # rubocop:disable Security/MarshalLoad Marshal.load(File.read(@file)) if File.exist?(@file) # rubocop:enable Security/MarshalLoad end def save(weights) File.write(@file, Marshal.dump(weights)) end end ================================================ FILE: model/linear.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'json' require 'time' require 'crack' require_relative 'predictor' require_relative 'storage' require_relative 'fake_weights_storage' # # Linear Model # @todo #532:60min Add unit-tests. # We should add unit-tests for this class that checks puzzle ranking. # For now its untested, don't forget to remove this puzzle. # class LinearModel def initialize(repo, storage) @repo = repo @xml_storage = storage if ENV['RACK_ENV'] == 'test' @storage = FakeWeightsStorage.new(@repo) else settings = Sinatra::Application.settings @storage = Storage.new( "#{@repo}.marshal", settings.config['s3']['bucket'], settings.config['s3']['region'], settings.config['s3']['key'], settings.config['s3']['secret'] ) end end # ranks the puzzles using Machine-Learning # @param puzzles XML puzzles # @return array of positional index of the input puzzles # @todo #532:60min Implement a ranked puzzles. # Let's implement a class that will use `LinearModel` to rank puzzles. # This class is need in order to do an integration between original 0pdd # and model modules. Probably it can be a decorator for `Puzzles` # that ranks XML puzzles, and then submits them into `Puzzles`. # Don't forget to remove this puzzle. def predict(puzzles) weights = @storage.load # load weights for repo from s3 clf = Predictor.new( layers: [ { name: 'w1', shape: [10, 1] }, { name: 'w2', shape: [1, 1] } ] ) if weights.nil? train(clf) # find weights for repo backlog of puzzles ranks = naive_rank(puzzles) # naive rank of puzzles in each repo else # get x and y data for puzzles samples, _labels = extract_features(puzzles) ranks = clf.predict(weights, samples[0]) # model rank of puzzles if weights are loaded end ranks.map(&:to_i) end private def replace_nil(arr, with = 0) arr.map { |x| x.nil? ? with : x } end def get_features_labels(samples) x = samples.map do |_, s| replace_nil([ s['time_estimate'], s['n_characters'], s['level'], s['n_puzzles_before'], s['n_puzzles_after'], s['time_before'], s['time_after'], s['n_additions'], s['n_deletions'] ].append(s['vectorized_description'])) end y = samples.map { |_, s| s['closed'] ? Time.parse(s['closed']).to_i : 0 }.map.with_index.sort.map(&:last) [[x], [y]] # single backlog of puzzles end # depth first feature extraction def extract_features(puzzles, samples = {}, level = 1) puzzles = [puzzles] unless puzzles.is_a?(Array) puzzles.each do |puzzle| next if puzzle.nil? prev_puzzle = samples[samples.keys.last] time_before = 0 unless prev_puzzle.nil? opened = Time.parse(prev_puzzle['time']).to_i closed = prev_puzzle['closed'] ? Time.parse(prev_puzzle['closed']).to_i : opened time_before = (closed - opened) / 60 # in minutes unless prev_puzzle['time_after'].nil? time_after = (Time.parse(puzzle['closed']).to_i - Time.parse(puzzle['time']).to_i) / 60 # in minutes prev_puzzle['time_after'] = time_after end end n_characters = puzzle['body'].gsub(/\s/, '').length samples[puzzle['id']] = { 'time_estimate' => puzzle['estimate'].to_i, 'n_characters' => n_characters, 'level' => level, 'n_puzzles_before' => samples.length, 'n_puzzles_after' => puzzles.length - samples.length, 'time_before' => time_before }.merge(puzzle) extract_features(puzzle['children']['puzzle'], samples, level + 1) unless puzzle['children'].nil? end get_features_labels(samples) if level == 1 end def train(clf) puzzles = @xml_storage.load Thread.new do # properly train model here and save weights to s3 for later puzzles = JSON.parse(Crack::XML.parse(puzzles.to_s).to_json)['puzzles'] unless puzzles.nil? samples, labels = extract_features(puzzles['puzzle']) if labels[0].length > 1 # train only when there's data center = ZeroVector.zero(samples[0][0].size) solver = Pso::Solver.new(f: clf, center: center, data: samples, true_order: labels) _rank, weights, _n_iterations = solver.solve @storage.save(weights) end end end end def naive_rank(puzzles) estimates = puzzles.map { |puzzle| puzzle['estimate'].to_i } estimates.map.with_index.sort.map(&:last) end end ================================================ FILE: model/predictor.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'pso/pso' def argsort(arr) arr.map.with_index.sort.map(&:last) end def normalised_kendall_tau_distance(a, b) raise 'Both lists have to be of equal length' unless a.size == b.size a = argsort(a) b = argsort(b) combination = a.combination(2) disordered = 0 combination.each do |i, j| is_disordered = (a[i] > a[j] && b[i] < b[j]) || (a[i] < a[j] && b[i] > b[j]) disordered += 1 if is_disordered end n = a.size (2.0 * disordered.to_f) / (n * (n - 1.0)) end def default_option_generator_linear(attribute_num) [ { layers: [{ name: 'w1', shape: [attribute_num, 1] }, { name: 'w2', shape: [1, 1] }] }, [attribute_num] + 1 ] end # # Linear Predictor Model # class Predictor def initialize(**options) @layers = {} @kendall_corr_history = [] options[:layers].each do |layer| @layers["#{layer[:name]}_shape"] = layer[:shape] end end def f(weights, **options) data = options[:data] true_order = options[:true_order] kns = [] (0...data.size).each do |i| x = data[i] y = true_order[i].first(x.size) preds = predict(weights, x) kn = normalised_kendall_tau_distance(preds, y) kns.append(kn) end kns.sum / kns.size # mean end def train(weights, data, true_order) ranks = predict(weights, data) normalised_kendall_tau_distance(ranks, true_order) end def predict(weights, data) ranks = [] (0...data.size).each do |i| row = data[i] r = forward_one(weights, row) ranks.append(r) end ranks end def forward_one(weights, data) x = data.clone.map(&:clone).flatten w = weights.first(x.size) x = Vector[*x].dot(Vector[*w]) w.map { |c| x += c }[0] end def kendall(weights, data, true_order) x = predict(weights, data) normalised_kendall_tau_distance(x, true_order) end end ================================================ FILE: model/pso/lib/function.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'matrix' module Pso # # General Objective Function Interface # class Function def f(vector, **_options) vector.magnitude end end end ================================================ FILE: model/pso/lib/functions/rastrigin.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative '../function' require_relative '../zero_vector' module Pso # # Rastrigin Objective Function # class Rastrigin < Pso::Function def f(vector, **_options) fitness = 10 * vector.size fitness + vector.sum { |n| (n**2) - (10 * Math.cos(2 * Math::PI * n)) } end end end ================================================ FILE: model/pso/lib/functions/schwefel.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative '../function' require_relative '../zero_vector' module Pso # # Schwefel Objective Function # class Schwefel < Pso::Function def f(vector, **_options) alpha = 418.982887 vector.sum { |n| -n * Math.sin(Math.sqrt(n.to_f.abs)) } + (alpha * vector.size) end end end ================================================ FILE: model/pso/lib/solver.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'zero_vector' require_relative 'functions/rastrigin' # rubocop:disable Metrics/ParameterLists module Pso # # PSO Solver # class Solver def initialize( din: 5, density: 50, f: Pso::Rastrigin, center: nil, radius: 5.12, method: :min_by, **options ) begin @f = f.new rescue NoMethodError @f = f end @din = din @center = center @radius = radius @method = method @density = density @options = options generate_swarm end def generate_swarm Array.new(@density) @swarm = Array.new(@density) { generate_random_particle } @swarm_best = @swarm.map { |particle| [@f.f(particle, **@options), particle] } @swarm_speed = @swarm.map { generate_random_particle } end def generate_random_noise_particle @center.map { (rand * 2) - 1 } end def generate_random_particle @center + (generate_random_noise_particle * (@radius * rand)) end def perfect_particle if @method == :min_by @swarm.min_by do |element| @f.f(element, **@options) end else @swarm.max_by do |element| @f.f(element, **@options) end end end def solve(precision: 100, threads: 1, debug: false) n_iterations = 0 Array.new(threads).map do Thread.new do ((precision / @swarm.size) / threads).times do |_| n_iterations += 1 (0...@density).each do |index| perfect = perfect_particle puts @f.f(perfect, **@options) if debug new_vector = normalize(iterate(@swarm[index], @swarm_best[index].last, perfect, @swarm_speed[index])) if best?(@swarm_best[index].first, @f.f(new_vector, **@options)) @swarm_best[index] = [@f.f(new_vector, **@options), new_vector] end @swarm_speed[index] = (new_vector - @swarm[index]).normalize @swarm[index] = new_vector end end end end.each(&:join) perfect = perfect_particle [@f.f(perfect, **@options), perfect, n_iterations] end private def best?(best, now) if @method == :min_by now < best else now > best end end def normalize(vector) return ((vector - @center).normalize * @radius) + @center if (vector - @center).magnitude > @radius vector end def iterate(vector, best, perfect, speed) if vector == perfect out = generate_random_noise_particle new_vec = vector + ((best - vector).normalize * 0.2) + (out * rand * 0.05) + (speed * 0.05) minimal = @f.f(vector, **@options) > @f.f(new_vec, **@options) return minimal ? new_vec : vector if @method == :min_by return minimal ? vector : new_vec unless @method == :min_by end out = generate_random_noise_particle vector + (out * rand * 0.1) + ((best - vector).normalize * 0.5) + (perfect - vector).normalize + speed end end end # rubocop:enable Metrics/ParameterLists ================================================ FILE: model/pso/lib/version.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT module Pso VERSION = '0.1.1'.freeze end ================================================ FILE: model/pso/lib/zero_vector.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'matrix' # # Zero vector class # class ZeroVector < Vector def normalize return self if zero? super end end ================================================ FILE: model/pso/pso.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'lib/version' require_relative 'lib/solver' # # PSO Module # module Pso end ================================================ FILE: model/storage.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'json' require 'aws-sdk-s3' require_relative '../version' # # S3 storage. # class Storage 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 return unless @object.exists? data = @object.get.body puts "S3 #{data.size} from #{@object.bucket_name}/#{@object.key}" # rubocop:disable Security/MarshalLoad Marshal.load(data) # rubocop:enable Security/MarshalLoad end def save(weights) data = Marshal.dump(weights) @object.put(body: data) puts "S3 #{data.size} to #{@object.bucket_name}/#{@object.key}" end end ================================================ FILE: nginx.conf.sigil ================================================ {{ range $port_map := .PROXY_PORT_MAP | split " " }} {{ $port_map_list := $port_map | split ":" }} {{ $scheme := index $port_map_list 0 }} {{ $listen_port := index $port_map_list 1 }} {{ $upstream_port := index $port_map_list 2 }} {{ if eq $scheme "http" }} server { listen [::]:{{ $listen_port }}; listen {{ $listen_port }}; {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} access_log {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-access.log; error_log {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-error.log; location / { gzip on; gzip_min_length 1100; gzip_buffers 4 32k; gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/x-javascript application/json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; gzip_vary on; gzip_comp_level 6; proxy_pass http://{{ $.APP }}-{{ $upstream_port }}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Request-Start $msec; } include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; error_page 400 401 402 403 405 406 407 408 409 410 411 412 413 414 415 416 417 418 420 422 423 424 426 428 429 431 444 449 450 451 /400-error.html; location /400-error.html { root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; internal; } error_page 404 /404-error.html; location /404-error.html { root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; internal; } error_page 500 501 502 503 504 505 506 507 508 509 510 511 /500-error.html; location /500-error.html { root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; internal; } } {{ else if eq $scheme "https"}} server { listen [::]:{{ $listen_port }} ssl {{ if eq $.HTTP2_SUPPORTED "true" }}http2{{ else if eq $.SPDY_SUPPORTED "true" }}spdy{{ end }}; listen {{ $listen_port }} ssl {{ if eq $.HTTP2_SUPPORTED "true" }}http2{{ else if eq $.SPDY_SUPPORTED "true" }}spdy{{ end }}; {{ if $.SSL_SERVER_NAME }}server_name {{ $.SSL_SERVER_NAME }}; {{ end }} {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} access_log {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-access.log; error_log {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-error.log; ssl_certificate {{ $.APP_SSL_PATH }}/server.crt; ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key; ssl_protocols TLSv1.2 {{ if eq $.TLS13_SUPPORTED "true" }}TLSv1.3{{ end }}; ssl_prefer_server_ciphers off; keepalive_timeout 70; {{ if and (eq $.SPDY_SUPPORTED "true") (ne $.HTTP2_SUPPORTED "true") }}add_header Alternate-Protocol {{ $.PROXY_SSL_PORT }}:npn-spdy/2;{{ end }} location / { gzip on; gzip_min_length 1100; gzip_buffers 4 32k; gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/x-javascript application/json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; gzip_vary on; gzip_comp_level 6; proxy_pass http://{{ $.APP }}-{{ $upstream_port }}; {{ if eq $.HTTP2_PUSH_SUPPORTED "true" }}http2_push_preload on; {{ end }} proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Request-Start $msec; } include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; error_page 400 401 402 403 405 406 407 408 409 410 411 412 413 414 415 416 417 418 420 422 423 424 426 428 429 431 444 449 450 451 /400-error.html; location /400-error.html { root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; internal; } error_page 404 /404-error.html; location /404-error.html { root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; internal; } error_page 500 501 503 504 505 506 507 508 509 510 511 /500-error.html; location /500-error.html { root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; internal; } error_page 502 /502-error.html; location /502-error.html { root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; internal; } } {{ else if eq $scheme "grpc"}} {{ if eq $.GRPC_SUPPORTED "true"}}{{ if eq $.HTTP2_SUPPORTED "true"}} server { listen [::]:{{ $listen_port }} http2; listen {{ $listen_port }} http2; {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} access_log {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-access.log; error_log {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-error.log; location / { grpc_pass grpc://{{ $.APP }}-{{ $upstream_port }}; } include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; } {{ end }}{{ end }} {{ else if eq $scheme "grpcs"}} {{ if eq $.GRPC_SUPPORTED "true"}}{{ if eq $.HTTP2_SUPPORTED "true"}} server { listen [::]:{{ $listen_port }} ssl http2; listen {{ $listen_port }} ssl http2; {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} access_log {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-access.log; error_log {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-error.log; ssl_certificate {{ $.APP_SSL_PATH }}/server.crt; ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key; ssl_protocols TLSv1.2 {{ if eq $.TLS13_SUPPORTED "true" }}TLSv1.3{{ end }}; ssl_prefer_server_ciphers off; location / { grpc_pass grpc://{{ $.APP }}-{{ $upstream_port }}; } include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; } {{ end }}{{ end }} {{ end }} {{ end }} {{ if $.DOKKU_APP_LISTENERS }} {{ range $upstream_port := $.PROXY_UPSTREAM_PORTS | split " " }} upstream {{ $.APP }}-{{ $upstream_port }} { {{ range $listeners := $.DOKKU_APP_LISTENERS | split " " }} {{ $listener_list := $listeners | split ":" }} {{ $listener_ip := index $listener_list 0 }} server {{ $listener_ip }}:{{ $upstream_port }};{{ end }} } {{ end }}{{ end }} ================================================ FILE: objects/clients/github.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'octokit' # # Github client # API: http://octokit.github.io/octokit.rb/method_list.html # class Github def initialize(config = {}) @config = config end def client if @config['testing'] require_relative '../../test/fake_github' FakeGithub.new else args = {} args[:access_token] = @config['github']['token'] if @config['github'] Octokit.connection_options = { request: { timeout: 20, open_timeout: 20 } } Octokit.auto_paginate = true Octokit::Client.new(args) end end end ================================================ FILE: objects/clients/gitlab.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'gitlab' # # Gitlab client # API: https://github.com/NARKOZ/gitlab # class GitlabClient def initialize(config = {}) @config = config end def client if @config['testing'] require_relative '../../test/fake_gitlab' FakeGitlab.new else token = @config['gitlab']['token'] if @config['gitlab'] Gitlab.client( endpoint: 'https://gitlab.com/api/v4', private_token: token, httparty: { headers: { 'Cookie' => 'gitlab_canary=true' } } ) end end end ================================================ FILE: objects/clients/jira.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'rubygems' require 'jira-ruby' # # Jira client # API: https://github.com/sumoheavy/jira-ruby # class JiraClient def initialize(config = {}) @config = config end def client if @config['testing'] # require_relative '../../test/fake_jira' # FakeJira.new else username = @config['jira']['username'] if @config['jira'] token = @config['jira']['token'] if @config['jira'] options = { username: username, password: token, site: 'http://localhost:8080/', # or 'https://.atlassian.net/' # often blank auth_type: :basic, read_timeout: 120 } JIRA::Client.new(options) end end end ================================================ FILE: objects/diff.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' # # Diff. # class Diff def initialize(before, after) @before = before @after = after end def notify(tickets) @after.xpath('//puzzle/ticket/text()').map(&:to_s).uniq.each do |t| current = summary(@after, t) previous = summary(@before, t) next if previous == current next if current.empty? tickets.notify(t, "#{current}.") end end private def issues(xml, *xpath) xpath.map { |x| xml.xpath(x) }.flatten.map do |p| issue = p.xpath('issue') if issue.empty? "`#{p.xpath('id')}`" else number = issue[0].text link = issue[0]['href'] number = link.split('/')[-1] if link && number == 'unknown' "[##{number}](#{link})" end end.sort end def summary(xml, ticket) all = issues( xml, "//puzzle[ticket='#{ticket}']/children//puzzle", "//puzzle[ticket='#{ticket}']" ) alive = issues( xml, "//puzzle[ticket='#{ticket}']/children//puzzle[@alive='true']", "//puzzle[ticket='#{ticket}' and @alive='true']" ) if alive.empty? if all.empty? '' elsif all.length == 1 "the only puzzle #{all[0]} is solved here" else "all #{all.length} puzzles are solved here: #{all.join(', ')}" end else solved = all - alive tail = solved.empty? ? '' : "; solved: #{solved.join(', ')}" if alive.length == 1 "the puzzle #{alive[0]} is still not solved" else "#{alive.length} puzzles #{alive.join(', ')} are still not solved" end + tail end end end ================================================ FILE: objects/dynamo.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'yaml' require 'aws-sdk-dynamodb' # # Dynamo client # class Dynamo def initialize(config = {}) @config = config end def aws Aws::DynamoDB::Client.new( if ENV['RACK_ENV'] == 'test' cfg = File.join(Dir.pwd, 'dynamodb-local/target/dynamo.yml') raise 'Test config is absent' unless File.exist?(cfg) yaml = YAML.safe_load(File.open(cfg)) { region: 'us-east-1', endpoint: "http://localhost:#{yaml['port']}", access_key_id: yaml['key'], secret_access_key: yaml['secret'], http_open_timeout: 5, http_read_timeout: 5 } else { region: @config['dynamo']['region'], access_key_id: @config['dynamo']['key'], secret_access_key: @config['dynamo']['secret'] } end ) end end ================================================ FILE: objects/git_repo.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'base64' require 'fileutils' require 'pdd' require 'qbash' require 'shellwords' require 'tempfile' require 'tmpdir' require 'yaml' require_relative 'user_error' # # Repository in Git # class GitRepo attr_reader :uri, :name, :path, :master, :head_commit_hash, :target def initialize( uri:, name:, master: 'master', head_commit_hash: '', **options ) @id = Base64.encode64(uri).gsub(%r{[\s=/]+}, '') @name = name @dir = options[:dir] || Dir.mktmpdir('0pdd') @path = "#{@dir}/#{@id}" @uri = uri @id_rsa = options[:id_rsa] || '' @master = master @head_commit_hash = head_commit_hash @target = options[:target] || 'master' end def lock "/tmp/0pdd-locks/#{@id}.txt" end def config f = File.join(@path, '.0pdd.yml') if File.exist?(f) YAML.safe_load(File.open(f)) else {} end end def xml raise "Path is absent: #{@path}" unless File.exist?(@path) Tempfile.open do |f| begin qbash("cd #{Shellwords.escape(@path)} && pdd -v -f #{Shellwords.escape(f.path)}") rescue StandardError => e raise UserError, e.message end Nokogiri::XML(File.read(f)) end end def push if File.exist?(@path) pull else clone end end def change_in_master? "refs/heads/#{master}".eql?(target) end private def clone prepare_key prepare_git qbash(['git clone', '--depth=1', '--quiet', Shellwords.escape(@uri), Shellwords.escape(@path)]) end def pull prepare_key prepare_git qbash( [ "cd #{Shellwords.escape(@path)}", "master=#{Shellwords.escape(@master)}", 'git config --local core.autocrlf false', 'git reset origin/${master} --hard --quiet', 'git clean --force -d', 'git fetch --quiet', 'git checkout origin/${master}', 'git rebase --abort || true', 'git rebase --autostash --strategy-option=theirs origin/${master}' ].join(' && ') ) end def prepare_key dir = "#{Dir.home}/.ssh" return if File.exist?(dir) FileUtils.mkdir_p(dir) File.write("#{dir}/id_rsa", @id_rsa) unless @id_rsa.empty? qbash( [ 'echo "Host *" > ~/.ssh/config', 'echo " StrictHostKeyChecking no" >> ~/.ssh/config', 'echo " UserKnownHostsFile=~/.ssh/known_hosts" >> ~/.ssh/config', 'chmod -R 600 ~/.ssh/*' ].join(';') ) end def prepare_git qbash( [ 'GIT=$(git --version)', 'if [[ "${GIT}" != "git version 2."* ]]', 'then echo "Git is too old: ${GIT}"', 'exit -1', 'fi' ].join(';') ) return if ENV['RACK_ENV'] == 'test' qbash( [ 'if ! git config --get --global user.email', 'then git config --global user.email "server@0pdd.com"', 'fi', 'if ! git config --get --global user.name', 'then git config --global user.name "0pdd.com"', 'fi' ].join(';') ) end end ================================================ FILE: objects/invitations/github_invitations.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT # # Invitations in Github # class GithubInvitations def initialize(github) @github = github end def accept @github.user_repository_invitations.each do |i| break if @github.rate_limit.remaining < 1000 puts "Repository invitation #{i['id']} accepted" if @github.accept_repository_invitation(i['id']) end end def accept_single_invitation(repo) invitations = @github.user_repository_invitations(repo: repo) invitations.map do |i| break if @github.rate_limit.remaining < 1000 "Repository invitation #{repo} accepted" if @github.accept_repository_invitation(i['id']) end end def accept_orgs @github.organization_memberships('state' => 'pending').each do |m| break if @github.rate_limit.remaining < 1000 org = m['organization']['login'] begin @github.update_organization_membership(org, 'state' => 'active') puts "Invitation for @#{org} accepted" rescue Octokit::NotFound # puts "Failed to join @#{org} organization: #{e.message}" @github.remove_organization_membership(org) # puts "Membership in @#{org} organization removed" end end end end ================================================ FILE: objects/invitations/github_organization_invitations.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'github_organization_invitation' # # Invitations to join Github organizations # class GithubOrganizationInvitations def initialize(github) @github = github end def all @github.organization_memberships(state: 'pending').collect do |membership| GithubOrganizationInvitation.new(membership, @github) end end end ================================================ FILE: objects/jobs/job.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'mail' require_relative '../diff' require_relative '../puzzles' # # One job. # class Job def initialize(vcs, storage, tickets) @vcs = vcs @storage = storage @tickets = tickets end def proceed @vcs.repo.push before = @storage.load Puzzles.new(@vcs.repo, @storage).deploy(@tickets) return if opts.include?('on-scope') Diff.new(before, @storage.load).notify(@tickets) end private def opts array = @vcs.repo.config.dig('alerts', 'suppress') array.nil? || !array.is_a?(Array) ? [] : array end end ================================================ FILE: objects/jobs/job_commiterrors.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative '../truncated' # # Job that posts exceptions as commit messages. # class JobCommitErrors def initialize(vcs, job) @vcs = vcs @job = job end def proceed @job.proceed rescue Exception => e done = @vcs.create_commit_comment( @vcs.repo.head_commit_hash, "I wasn't able to retrieve PDD puzzles from the code base and \ submit them to #{@vcs.name}. If you \ think that it's a bug on our side, please submit it to \ [yegor256/0pdd](https://github.com/yegor256/0pdd/issues):\n\n\ > #{Truncated.new(e.message.gsub(/\s/, ' '), 300)}\n\n Please, copy and paste this stack trace to GitHub:\n\n ```\n#{e.class.name}\n#{e.message}\n#{e.backtrace.join("\n")}\n```" ) puts "Comment posted about an error: #{done[:html_url]}" raise e end end ================================================ FILE: objects/jobs/job_detached.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'fileutils' # # One job. # class JobDetached def initialize(vcs, job) @vcs = vcs @job = job end def proceed if ENV['RACK_ENV'] == 'test' exclusive else Process.detach(fork { exclusive }) end end private def exclusive lock = @vcs.repo.lock FileUtils.mkdir_p(File.dirname(lock)) f = File.open(lock, File::RDWR | File::CREAT, 0o644) f.flock(File::LOCK_EX) begin @job.proceed ensure f.close begin File.delete(lock) rescue Errno::EACCES lock.close File.delete(lock) end end end end ================================================ FILE: objects/jobs/job_emailed.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'mail' # # Job that emails if exception occurs. # class JobEmailed def initialize(vcs, job) @vcs = vcs @job = job end def proceed @job.proceed rescue Exception => e yaml = @vcs.repo.config emails = yaml['errors'] || [] emails << 'admin@0pdd.com' trace = "#{e.message}\n\n#{e.backtrace.join("\n")}" name = @vcs.repo.name repo_owner_login = repo_user_login repo_owner_email = user_email(repo_owner_login) repository_link = @vcs.repository_link emails.each do |email| mail = Mail.new do from '0pdd ' to email subject "#{name}: puzzles discovery problem" text_part do content_type 'text/plain; charset=UTF-8' body "Hey,\n\n\ There is a problem in #{repository_link}:\n\n\ #{trace}\n\n\ If you think it's our bug, please submit it to GitHub: \ https://github.com/yegor256/0pdd/issues\n\n\ Sorry,\n\ 0pdd" end html_part do content_type 'text/html; charset=UTF-8' body "

Hey,

There is a problem in #{name}:

#{trace}

If you think it's our bug, please submit it to GitHub. Thanks.

Sorry,
0pdd

" 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, 'hello')) assert_equal('hello', storage.load.xpath('/test/text()')[0].text) end end end ================================================ FILE: test/test_commit_tickets.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'yaml' require_relative 'test__helper' require_relative '../objects/tickets/commit_tickets' # CommitTickets test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestCommitTickets < Minitest::Test def test_submits_tickets config = YAML.safe_load( " alerts: suppress: - on-found-puzzle" ) vcs = object(repo: { config: config }) tickets = Object.new def tickets.submit(_) {} end tickets = CommitTickets.new(vcs, tickets) tickets.submit(nil) end def test_closes_tickets config = YAML.safe_load( " alerts: suppress: - on-lost-puzzle" ) vcs = object(repo: { config: config }) tickets = Object.new def tickets.close(_) {} end tickets = CommitTickets.new(vcs, tickets) tickets.close(nil) end def test_scope_suppressed_repo_should_be_quiet config = YAML.safe_load( " alerts: suppress: - on-found-puzzle" ) vcs = object(repo: { config: config }) tickets = Object.new def tickets.submit(_) {} end tickets = CommitTickets.new(vcs, tickets) tickets.submit(nil) end end ================================================ FILE: test/test_credentials.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'mail' require 'yaml' require 'octokit' require 'tmpdir' require 'aws-sdk-dynamodb' require_relative 'test__helper' require_relative '../objects/storage/s3' require_relative '../objects/tickets/tickets' require_relative '../objects/log' require_relative '../objects/vcs/github' require_relative '../objects/git_repo' class CredentialsTest < Minitest::Test def test_connects_to_git_via_ssh cfg = config Dir.mktmpdir 'test' do |d| repo = GitRepo.new( uri: 'git@github.com:yegor256/0pdd', name: 'yegor256/0pdd', id_rsa: cfg['id_rsa'], dir: d ) repo.push refute_nil(repo.xml.xpath('//puzzles')) end end def test_connects_to_aws_dynamo cfg = config dynamo = Aws::DynamoDB::Client.new( region: cfg['dynamo']['region'], access_key_id: cfg['dynamo']['key'], secret_access_key: cfg['dynamo']['secret'] ) refute(Log.new(dynamo, 'yegor256/0pdd').exists('some stupid tag')) end def test_connects_to_github cfg = config github = Octokit::Client.new( access_token: cfg['github']['token'] ) tickets = Tickets.new( GithubRepo.new( github, { 'repository' => { 'full_name' => 'yegor256/0pdd', 'url' => 'https://github.com/yegor256/0pdd', 'master_branch' => 'master' }, 'ref' => 'master', 'head_commit' => { 'id' => '---' } } ) ) tickets.close( Nokogiri::XML( 'AA1' ).xpath('/puzzle') ) end def test_connects_to_aws_s3 cfg = config storage = S3.new( 'yegor256/0pdd.xml', cfg['s3']['bucket'], cfg['s3']['region'], cfg['s3']['key'], cfg['s3']['secret'] ) refute_nil(storage.load.xpath('//puzzles')) end def test_sends_email_via_smtp cfg = config Mail.defaults do delivery_method( :smtp, address: cfg['smtp']['host'], port: cfg['smtp']['port'], user_name: cfg['smtp']['user'], password: cfg['smtp']['password'], domain: '0pdd.com', enable_starttls_auto: true ) end mail = Mail.new do from '0pdd ' to 'admin@0pdd.com' subject 'Test email, ignore it' text_part do content_type 'text/plain; charset=UTF-8' body 'It it a test email, ignore it.' end end mail.deliver! end private def config file = File.join(File.dirname(__FILE__), '../config.yml') file = ENV['PDD_CONFIG'] if ENV['PDD_CONFIG'] skip('...') unless File.exist?(file) YAML.safe_load(File.open(file)) end end ================================================ FILE: test/test_diff.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require 'ostruct' require_relative 'test__helper' require_relative '../objects/diff' # Diff test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestDiff < Minitest::Test def test_notification_on_one_new_puzzle tickets = Tickets.new Diff.new( Nokogiri::XML(''), Nokogiri::XML( ' 1-abcdef 5 5-abcdef 6 5 ' ) ).notify(tickets) assert_equal( 1, tickets.messages.length, "Incorrect number of messages: #{tickets.messages.length}" ) assert_equal( '5 the puzzle [#6](#) is still not solved.', tickets.messages[0], "Text is wrong: #{tickets.messages[0]}" ) end def test_notification_unknown_issue tickets = Tickets.new xml = File.open('test-assets/puzzles/notify-unknown-open-issues.xml') do |f| Nokogiri::XML(f) end Diff.new(Nokogiri::XML(''), xml).notify(tickets) assert_equal( 1, tickets.messages.length, "Incorrect number of messages: #{tickets.messages.length}" ) assert_equal( '5 the puzzle [#125](//issue/125) is still not solved.', tickets.messages[0], "Text is wrong: #{tickets.messages[0]}" ) end def test_notification_on_two_new_puzzles tickets = Tickets.new Diff.new( Nokogiri::XML(''), Nokogiri::XML( ' 1-abcdef 55 5-abcdee 66 55 5-abcded 77 55 ' ) ).notify(tickets) assert_equal( 1, tickets.messages.length, "Incorrect number of messages: #{tickets.messages.length}" ) assert_equal( '55 2 puzzles [#66](#), [#77](#) are still not solved.', tickets.messages[0], "Text is wrong: #{tickets.messages[0]}" ) end def test_notification_on_solved_puzzle tickets = Tickets.new before = Nokogiri::XML( ' 100-ffffff 100 500 ' ) after = Nokogiri::XML(before.to_s) after.xpath('//puzzle[id="100-ffffff"]')[0]['alive'] = 'false' Diff.new(before, after).notify(tickets) assert_equal( 1, tickets.messages.length, "Incorrect number of messages: #{tickets.messages.length}" ) assert_equal( '500 the only puzzle [#100]() is solved here.', tickets.messages[0], "Text is wrong: #{tickets.messages[0]}" ) end def test_notification_on_one_solved_puzzle tickets = Tickets.new before = Nokogiri::XML( ' 100-1 100 999 100-2 101 999 101-1 13 101 ' ) after = Nokogiri::XML(before.to_s) after.xpath('//puzzle[id="100-1"]')[0]['alive'] = 'false' Diff.new(before, after).notify(tickets) assert_equal( 1, tickets.messages.length, "Wrong about of msgs (#{tickets.messages.length}): #{tickets.messages}" ) assert_equal( '999 the puzzle [#13]() is still not solved; solved: [#100](), [#101]().', tickets.messages[0], "Text is wrong: #{tickets.messages[0]}" ) end def test_notification_on_update tickets = Tickets.new before = Nokogiri::XML( ' 1-abcdef 5 5-abcdef 6 5 ' ) after = Nokogiri::XML(before.to_s) after.xpath('//puzzle[id="5-abcdef"]')[0]['alive'] = 'false' Diff.new(before, after).notify(tickets) assert_equal( 1, tickets.messages.length, "Wrong about of msgs (#{tickets.messages.length}): #{tickets.messages}" ) assert_equal( '5 the only puzzle [#6](#) is solved here.', tickets.messages[0], "Text is wrong: #{tickets.messages[0]}" ) end def test_quiet_when_no_changes tickets = Tickets.new xml = ' 1-abcdef 50 50-abcdef 60 ' Diff.new( Nokogiri::XML(xml), Nokogiri::XML(xml) ).notify(tickets) assert_empty(tickets.messages) end class Tickets attr_reader :messages def initialize @messages = [] end def notify(ticket, text) @messages << "#{ticket} #{text}" end end end ================================================ FILE: test/test_diff_complicated.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require 'ostruct' require_relative 'test__helper' require_relative '../objects/diff' # Complicated diff test. class TestDiff < Minitest::Test # @todo #234:15m Add tests for more complicated dynamics, like # [here](https://github.com/php-coder/mystamps/issues/695#issuecomment-405372820). # Ideally, this tests other cases that can lead to the observed behaviour, # but not covered by the test suite. def test_notification_on_parent_solved_with_others_unsolved tickets = Tickets.new before = Nokogiri::XML( ' 100-1 100 999 100-2 101 999 101-1 13 101 ' ) after = Nokogiri::XML(before.to_s) after.xpath('//puzzle[id="100-2"]')[0]['alive'] = 'false' Diff.new(before, after).notify(tickets) assert_equal( 1, tickets.messages.length, "Wrong about of msgs (#{tickets.messages.length}): #{tickets.messages}" ) assert_equal( '999 2 puzzles [#100](), [#13]() are still not solved; solved: [#101]().', tickets.messages[0], "Text is wrong: #{tickets.messages[0]}" ) end class Tickets attr_reader :messages def initialize @messages = [] end def notify(ticket, text) @messages << "#{ticket} #{text}" end end end ================================================ FILE: test/test_git_repo.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'tmpdir' require_relative 'test__helper' require_relative '../objects/git_repo' require_relative '../objects/user_error' # GitRepo test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestGitRepo < Minitest::Test def test_clone_and_pull Dir.mktmpdir 'test' do |d| _, uri = git(d) repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri) repo.push repo.push assert_path_exists(File.join(repo.path, '.git')) end end def test_merge_unrelated_histories Dir.mktmpdir 'test' do |d| path, uri = git(d, 'repo') repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri) repo.push qbash(" set -e cd '#{Shellwords.escape(path)}' git checkout -b temp git branch -D master git checkout --orphan master echo 'hello, dude!' > new.txt git add new.txt git commit --no-verify --quiet -am 'new master' ") repo.push assert_path_exists(File.join(repo.path, 'new.txt')) end end def test_fail_with_user_error Dir.mktmpdir 'test' do |d| path, uri = git(d, 'repo') repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri) repo.push qbash( " set -e cd '#{Shellwords.escape(path)}' echo '...\x40todoBad puzzle' > z1.txt echo '\x40todo #1 Good puzzle' > z2.txt git add z1.txt z2.txt git commit --no-verify --quiet --amend --message 'zz' " ) repo.push assert_raises(UserError) do repo.xml end end end def test_merge_after_amend Dir.mktmpdir 'test' do |d| path, uri = git(d, 'repo') repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri) repo.push qbash(" set -e cd '#{Shellwords.escape(path)}' echo 'hello, dude!' > z.txt git add z.txt git commit --no-verify --quiet --amend --message 'new fix' ") repo.push assert_path_exists(File.join(repo.path, 'z.txt')) end end def test_merge_after_force_push Dir.mktmpdir 'test' do |d| path, uri = git(d, 'repo') repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri) repo.push qbash(" set -e cd '#{Shellwords.escape(path)}' git reset HEAD~2 git reset --hard git clean -fd echo 'hello, dude!' >> z.txt && git add z.txt && git commit --no-verify -m ddd echo 'hello, dude!' >> z.txt && git add z.txt && git commit --no-verify -m ddd echo 'hello, dude!' >> z.txt && git add z.txt && git commit --no-verify -m ddd ") repo.push assert_path_exists(File.join(repo.path, 'z.txt')) end end def test_merge_after_complete_new_master Dir.mktmpdir 'test' do |d| path, uri = git(d, 'repo') repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri) repo.push qbash(" set -e cd '#{Shellwords.escape(path)}' git checkout -b temp git branch -D master git checkout --orphan master echo 'hello, new!' >> z.txt && git add z.txt && git commit --no-verify -m ddd echo 'hello, new!' >> z.txt && git add z.txt && git commit --no-verify -m ddd echo 'hello, new!' >> z2.txt && git add z2.txt && git commit --no-verify -m ddd ") repo.push assert_path_exists(File.join(repo.path, 'z.txt')) assert_path_exists(File.join(repo.path, 'z2.txt')) end end def test_doesnt_touch_crlf skip('...') # I can't reproduce the problem of #125. The code works as it should # be, however in production it fails due to some issues with CRLF # in binary files. # See also: https://stackoverflow.com/questions/46539254 Dir.mktmpdir 'test' do |d| path, uri = git(d, 'repo') repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri) qbash(" set -e cd '#{Shellwords.escape(path)}' git config --local core.autocrlf false echo -n -e 'Hello, world!\r\nHow are you?' >> crlf.txt \ && git add . && git commit --no-verify -am crlf.txt ") repo.push assert_equal( "Hello, world!\n\rHow are you?", File.read(File.join(repo.path, 'crlf.txt')) ) end end def test_push Dir.mktmpdir 'test' do |d| _, uri = git(d) repo = GitRepo.new(name: 'teamed/est', dir: d, uri: uri) repo.push repo.push assert_path_exists(File.join(repo.path, '.git')) end end def test_fetch_puzzles Dir.mktmpdir 'test' do |d| _, uri = git(d) repo = GitRepo.new(name: 'yegor256/0pdd', dir: d, uri: uri) repo.push refute_empty(repo.xml.xpath('/puzzles')) end end def test_fetch_config clean_dir = '' begin Dir.mktmpdir 'test' do |d| clean_dir = d _, uri = git(d) repo = GitRepo.new(name: 'yegor256/0pdd', dir: d, uri: uri) repo.push assert(repo.config['foo']) end rescue Errno::ENOTEMPTY FileUtils.remove_entry(clean_dir, true) end end private def git(dir, subdir = 'repo') qbash(" set -e cd '#{Shellwords.escape(dir)}' git init --quiet #{Shellwords.escape(subdir)} cd #{Shellwords.escape(subdir)} git config user.email git@0pdd.com git config user.name 0pdd echo 'foo: hello' > .0pdd.yml git add .0pdd.yml git commit --no-verify --quiet -am 'add line' echo 'hello, world!' >> z.txt && git add z.txt && git commit --no-verify -am z echo 'hello, world!' >> z.txt && git add z.txt && git commit --no-verify -am z echo 'hello, world!' >> z.txt && git add z.txt && git commit --no-verify -am z echo 'hello, world!' >> z.txt && git add z.txt && git commit --no-verify -am z echo 'hello, world!' >> z.txt && git add z.txt && git commit --no-verify -am z ") path = File.join(dir, subdir) [path, "file://#{path}"] end end ================================================ FILE: test/test_github.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'test__helper' require_relative '../objects/clients/github' # Github test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestGithub < Minitest::Test def test_configures_everything_right github = Github.new.client assert_equal('0pdd', github.user('0pdd')[:login], "Real user is #{github.user('0pdd')[:login]}") end end ================================================ FILE: test/test_github_invitations.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'test__helper' require_relative 'fake_github' require_relative '../objects/invitations/github_invitations' # GithubInvitations test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestGithubInvitation < Minitest::Test def test_accepts_organization_invitations organizations = %w[github google microsoft zerocracy] orgs = %w[github zerocracy] github = FakeGithub.new( memberships: organizations.collect do |org| { 'state' => orgs.include?(org) ? 'active' : 'pending', 'organization' => { 'login' => org } } end ) invitations = GithubInvitations.new(github) invitations.accept_orgs organizations.map do |org| assert( github.organization_memberships.find do |m| m['state'] == 'active' && m['organization']['login'] == org end ) end end def test_accepts_repository_invitations repositories = %w[yegor256/0pdd yegor256/sixnines] github = FakeGithub.new( invitations: repositories.enum_for(:each_with_index).collect do |repo, i| { 'id' => i, 'repository' => { 'name' => repo } } end ) GithubInvitations.new(github).accept repositories.map { |repo| assert_includes(github.repositories, repo) } end end ================================================ FILE: test/test_github_tickets.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require 'yaml' require_relative 'test__helper' require_relative '../objects/tickets/tickets' # GithubTickets test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestGithubTickets < Minitest::Test def test_submits_tickets config = YAML.safe_load( " alerts: github: - yegor256 - davvd format: - short-title - title-length=30 " ) repo = object( name: 'github', config: config, head_commit_hash: '123', master: 'master' ) require_relative 'fake_github' vcs = FakeGithub.new(repo: repo) def vcs.create_issue(data) @data = data { number: 1, html_url: 'url' } end class << vcs attr_accessor :data end tickets = Tickets.new(vcs) tickets.submit( Nokogiri::XML( ' 23-ab536de /a/b/c/test.txt yegor привет дорогой друг, как твои дела? 123 30 DEV 1-3
' ).xpath('/puzzle') ) assert_equal('привет дорогой друг, как...', vcs.data[:title]) assert(vcs.data[:description].start_with?('The puzzle `23-ab536de` from #123 has')) end def test_submits_tickets_log_title config = YAML.safe_load("\n\n") repo = object( name: 'github', config: config, head_commit_hash: '123', master: 'master' ) require_relative 'fake_github' vcs = FakeGithub.new(repo: repo) def vcs.create_issue(data) @data = data { number: 1, html_url: 'url' } end class << vcs attr_accessor :data end tickets = Tickets.new(vcs) tickets.submit( Nokogiri::XML( ' 55-ab536de /a/bz.txt yegor как дела? hey, how are you, please see this title! 123 30 DEV 1-3 ' ).xpath('/puzzle') ) assert_equal( 'bz.txt:1-3: как дела? hey, how are you, please see this...', vcs.data[:title] ) assert(vcs.data[:description].start_with?('The puzzle `55-ab536de` from #123 has')) end def test_output_estimates_when_it_is_not_zero config = YAML.safe_load("\n\n") repo = object( name: 'github', config: config, head_commit_hash: '123', master: 'master' ) require_relative 'fake_github' vcs = FakeGithub.new(repo: repo) def vcs.create_issue(data) @data = data { number: 1, html_url: 'url' } end class << vcs attr_accessor :data end tickets = Tickets.new(vcs) tickets.submit( Nokogiri::XML( ' 55-ab536de /a/bz.txt yegor как дела? hey, how are you, please see this title! 123 10 DEV 1-3 ' ).xpath('/puzzle') ) assert(vcs.data[:description].start_with?('The puzzle `55-ab536de` from #123 has')) assert_includes(vcs.data[:description], 'Estimate:') end def test_skips_estimate_if_zero config = YAML.safe_load("\n\n") repo = object( name: 'github', config: config, head_commit_hash: '123', master: 'master' ) require_relative 'fake_github' vcs = FakeGithub.new(repo: repo) def vcs.create_issue(data) @data = data { number: 1, html_url: 'url' } end class << vcs attr_accessor :data end tickets = Tickets.new(vcs) tickets.submit( Nokogiri::XML( ' 55-ab536de /a/bz.txt yegor как дела? hey, how are you, please see this title! 123 0 DEV 1-3 ' ).xpath('/puzzle') ) assert(vcs.data[:description].start_with?('The puzzle `55-ab536de` from #123 has')) refute_includes(vcs.data[:description], 'Estimate:') end def test_closes_tickets config = YAML.safe_load("alerts:\n github:\n - yegor256\n - davvd") repo = object( name: 'github', config: config, head_commit_hash: '123', master: 'master' ) require_relative 'fake_github' tickets = Tickets.new(FakeGithub.new(repo: repo)) tickets.close( Nokogiri::XML( 'xx1' ).xpath('/puzzle') ) end end ================================================ FILE: test/test_gitlab.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'test__helper' require_relative '../objects/clients/gitlab' # Github test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestGitlab < Minitest::Test def test_configures_everything_right gitlab = GitlabClient.new.client assert_raises Gitlab::Error::MissingCredentials do gitlab.user('0pdd')['username'] end end end ================================================ FILE: test/test_job.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require 'tmpdir' require_relative 'test__helper' require_relative 'fake_repo' require_relative 'fake_github' require_relative 'fake_tickets' require_relative 'fake_storage' require_relative '../objects/jobs/job' require_relative '../objects/storage/safe_storage' # Job test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestJob < Minitest::Test def test_simple_scenario Dir.mktmpdir 'test' do |d| repo = FakeRepo.new vcs = FakeGithub.new(repo: repo) Job.new( vcs, SafeStorage.new(FakeStorage.new(d)), FakeTickets.new ).proceed end end end ================================================ FILE: test/test_job_commiterrors.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'test__helper' require_relative '../objects/jobs/job_commiterrors' # JobCommitErrors test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestJobCommitErrors < Minitest::Test class Stub attr_reader :name, :reported, :repo def initialize(repo) @repo = repo @name = 'GITHUB' end def create_commit_comment(_, text) @reported = text end end def test_timeout_scenario job = Object.new def job.proceed raise 'Intended to be here' end vcs = Stub.new(object(head_commit_hash: '123')) begin JobCommitErrors.new(vcs, job).proceed rescue StandardError => e refute_nil(e) end refute_empty(vcs.reported) end end ================================================ FILE: test/test_job_detached.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'test__helper' require_relative '../objects/jobs/job_detached' # JobDetached test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestJobDetached < Minitest::Test def test_simple_scenario job = Object.new def job.proceed # nothing end require_relative 'fake_repo' vcs = object(repo: nil) vcs.repo = FakeRepo.new JobDetached.new(vcs, job).proceed end end ================================================ FILE: test/test_job_emailed.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'veil' require_relative '../objects/jobs/job_emailed' require_relative 'fake_github' require_relative 'fake_repo' require_relative 'test__helper' # JobEmailed test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestJobEmailed < Minitest::Test def fake_job Veil.new(Object.new, proceed: nil) end def test_simple_scenario repo = FakeRepo.new vcs = FakeGithub.new(repo: repo) job = fake_job JobEmailed.new(vcs, job).proceed end def test_exception_mail_to_repo_owner_as_cc skip('this test needs proper mocking') repo = FakeRepo.new vcs = FakeGithub.new(repo: repo) job = fake_job assert_raises(StandardError) do JobEmailed.new(vcs, job).proceed end end end ================================================ FILE: test/test_log.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require 'tmpdir' require_relative 'test__helper' require_relative '../objects/log' require_relative '../objects/dynamo' # Log test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestLog < Minitest::Test def test_put_and_check log = Log.new(Dynamo.new.aws, 'yegor256/0pdd') tag = 'some-tag' log.put(tag, 'some text here') assert(log.exists(tag)) end end ================================================ FILE: test/test_logged_storage.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'test__helper' require_relative 'fake_storage' require_relative 'fake_log' require_relative '../objects/storage/logged_storage' require_relative '../objects/storage/versioned_storage' # LoggedStorage test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestLoggedStorage < Minitest::Test def test_simple_xml_saving storage = LoggedStorage.new( VersionedStorage.new(FakeStorage.new, '0.0.1'), FakeLog.new ) storage.save(Nokogiri::XML('hello')) assert_equal('hello', storage.load.xpath('/test/text()')[0].text) end end ================================================ FILE: test/test_logged_tickets.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require 'yaml' require_relative 'test__helper' require_relative 'fake_log' require_relative 'fake_tickets' require_relative '../objects/tickets/logged_tickets' # LoggedTickets test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestLoggedTickets < Minitest::Test def test_submits_tickets log = FakeLog.new tickets = LoggedTickets.new('yegor256/0pdd', log, FakeTickets.new) tickets.submit( Nokogiri::XML( ' 23-ab536de /a/b/c/test.txt hey! 1-3 ' ).xpath('/puzzle') ) assert_equal('23-ab536de/submit', log.tag) assert_equal( '23-ab536de submitted in issue #123: "hey!" at /a/b/c/test.txt; 1-3', log.title ) end def test_closes_tickets log = FakeLog.new tickets = LoggedTickets.new('yegor256/0pdd', log, FakeTickets.new) tickets.close( Nokogiri::XML( ' 23-ab536fe 1 ' ).xpath('/puzzle') ) assert_equal('23-ab536fe/closed', log.tag) assert_equal( '23-ab536fe closed in issue #1', log.title ) end end ================================================ FILE: test/test_maybe_text.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'test__helper' require_relative '../objects/maybe_text' # Truncated test. class TestMaybeText < Minitest::Test def test_nil_input_then_blank assert_equal('', MaybeText.new('output', nil).to_s) end def test_empty_input_then_blank assert_equal('', MaybeText.new('output', '').to_s) end def test_excluded_input_then_blank assert_equal('', MaybeText.new('output', 'exc', exclude_if: 'exc').to_s) end def test_present_input_then_output assert_equal('output', MaybeText.new('output', 'input').to_s) end def test_show_output_when_exclude_if_is_present assert_equal('output', MaybeText.new('output', 'input', exclude_if: 'output').to_s) end end ================================================ FILE: test/test_milestone_tickets.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require 'yaml' require 'fake_github' require_relative 'test__helper' require_relative '../objects/tickets/milestone_tickets' # MilestoneTickets test. # Author:: George Aristy (george.aristy@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestGithubTickets < Minitest::Test def test_sets_milestone milestone = 123 config = YAML.safe_load( " tickets: - inherit-milestone alerts: suppress: - on-inherited-milestone " ) vcs = FakeGithub.new(repo: object(config: config)) def vcs.issue(_) { milestone: { number: 123, title: 'v1.0' } } end def vcs.update_issue(_, options) @milestone = options[:milestone] end class << vcs attr_accessor :milestone end tickets = Object.new def tickets.submit(_) { number: 456, href: 'http://0pdd.com' } end test = MilestoneTickets.new(vcs, tickets) test.submit( Nokogiri::XML( ' 23-ab536de /a/b/c/test.txt yegor привет дорогой друг, как твои дела? 456 30 DEV 1-3 ' ).xpath('/puzzle') ) assert_equal(milestone, vcs.milestone) end def test_does_not_set_milestone config = YAML.safe_load( ' alerts: suppress: - on-inherited-milestone ' ) vcs = FakeGithub.new(repo: object(config: config)) def vcs.issue(_) { 'milestone' => { 'number' => 123, 'title' => 'v1.0' } } end def vcs.update_issue(_, _) @updated = true end class << vcs attr_accessor :updated end tickets = Object.new def tickets.submit(_) { number: 123, href: 'http://0pdd.com' } end test = MilestoneTickets.new(vcs, tickets) test.submit( Nokogiri::XML( ' 23-ab536de /a/b/c/test.txt yegor привет дорогой друг, как твои дела? 123 30 DEV 1-3 ' ).xpath('/puzzle') ) refute(vcs.updated) end def test_adds_comment config = YAML.safe_load( ' tickets: - inherit-milestone ' ) vcs = FakeGithub.new(repo: object(config: config)) def vcs.issue(_) { milestone: { number: 123, title: 'v1.0' } } end def vcs.update_issue(_, _) # do nothing end def vcs.add_comment(_, text) @comment = text end class << vcs attr_accessor :comment end tickets = Object.new def tickets.submit(_) { number: 123, href: 'http://0pdd.com' } end test = MilestoneTickets.new(vcs, tickets) test.submit( Nokogiri::XML( ' 23-ab536de /a/b/c/test.txt yegor привет дорогой друг, как твои дела? 123 30 DEV 1-3 ' ).xpath('/puzzle') ) assert(vcs.comment.start_with?('This puzzle inherited milestone')) end end ================================================ FILE: test/test_once_storage.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'test__helper' require_relative 'fake_storage' require_relative '../objects/storage/once_storage' # OnceStorage test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestOnceStorage < Minitest::Test def test_never_saves_duplicates origin = TestStorage.new storage = OnceStorage.new(origin) storage.save(Nokogiri::XML('hello')) assert_equal(0, origin.count) end def test_saves_only_once origin = TestStorage.new storage = OnceStorage.new(origin) storage.save(Nokogiri::XML('bye')) assert_equal(1, origin.count) end class TestStorage attr_reader :count def initialize @count = 0 end def load Nokogiri::XML('hello') end def save(_) @count += 1 end end end ================================================ FILE: test/test_puzzles.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require 'ostruct' require 'tmpdir' require_relative 'test__helper' require_relative 'fake_storage' require_relative 'fake_tickets' require_relative '../version' require_relative '../objects/git_repo' require_relative '../objects/puzzles' require_relative '../objects/storage/safe_storage' require_relative '../objects/storage/versioned_storage' # Puzzles test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestPuzzles < Minitest::Test def test_all_xml Dir.mktmpdir 'test' do |d| test_xml(d, 'simple.xml') test_xml(d, 'closes-one-puzzle.xml') test_xml(d, 'ignores-unknown-issues.xml') test_xml(d, 'submits-old-puzzles.xml') test_xml(d, 'submits-three-tickets.xml') # test_xml(d, 'submits-ranked-puzzles.xml', ordered: true) end end def test_with_broken_tickets tickets = Object.new def tickets.submit(_) nil end xml = File.open('test-assets/puzzles/simple.xml') { |f| Nokogiri::XML(f) } Dir.mktmpdir 'test' do |dir| Puzzles.new( OpenStruct.new( xml: Nokogiri.XML(xml.xpath('/test/snapshot/puzzles')[0].to_s), config: {} ), FakeStorage.new( dir, Nokogiri.XML('') ) ).deploy(tickets) end end private def test_xml(dir, name, ordered: false) xml = File.open("test-assets/puzzles/#{name}") { |f| Nokogiri::XML(f) } storage = VersionedStorage.new( SafeStorage.new( FakeStorage.new( dir, Nokogiri.XML(xml.xpath('/test/before/puzzles')[0].to_s) ) ), '0.0.1' ) repo = OpenStruct.new( xml: Nokogiri.XML(xml.xpath('/test/snapshot/puzzles')[0].to_s), config: {} ) tickets = FakeTickets.new Puzzles.new(repo, storage).deploy(tickets) xml.xpath('/test/assertions/xpath/text()').each do |xpath| after = storage.load refute_empty( after.xpath(xpath.text), "#{xpath} not found in #{after}" ) end xml.xpath('/test/submit/ticket/text()').each_with_index do |id, idx| submitted = ordered ? tickets.submitted[idx] == id.text : tickets.submitted.include?(id.text) assert( submitted, "Puzzle #{id} was not submitted: #{tickets.submitted}" ) end xml.xpath('/test/close/ticket/text()').each do |ticket| assert_includes( tickets.closed, ticket.text, "Ticket #{ticket} was not closed: #{tickets.closed}" ) end tickets.closed.each do |ticket| refute_empty( xml.xpath("/test/close[ticket='#{ticket}']"), "Ticket #{ticket} was closed by mistake" ) end end end ================================================ FILE: test/test_safe_storage.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require_relative 'test__helper' require_relative 'fake_storage' require_relative 'fake_log' require_relative '../objects/storage/safe_storage' # SafeStorage test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestSafeStorage < Minitest::Test def test_accepts_valid_xml storage = SafeStorage.new(FakeStorage.new) storage.save( Nokogiri::XML( ' unknown 1 10 DEV 1-5e0e29d8 7-7 create mvvm model (see main page) for this page attendance/lib/login_page.dart @ammaratef45 a_atef_test@gmail-test.com ' ) ) end def test_rejects_invalid_xml storage = SafeStorage.new(FakeStorage.new) assert_raises(RuntimeError) do storage.save(Nokogiri::XML('hello')) end end end ================================================ FILE: test/test_sentry_tickets.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'mail' require_relative 'test__helper' require_relative '../objects/tickets/sentry_tickets' # SentryTickets test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestSentryTickets < Minitest::Test def test_exception_catching_on_submit tickets = Object.new def tickets.submit(_) raise 'submit failure' end assert_raises(StandardError) do SentryTickets.new(tickets).submit(0) end end def test_exception_catching_on_close tickets = Object.new def tickets.close(_) raise 'close failure' end assert_raises(StandardError) do SentryTickets.new(tickets).close(0) end end end ================================================ FILE: test/test_svg.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require_relative 'test__helper' # SVG badge test. class TestSvg < Minitest::Test XSL = Nokogiri::XSLT(File.read(File.expand_path('../assets/xsl/svg.xsl', __dir__))) def render(alive:, dead: 0) body = ('' * alive) + ('' * dead) XSL.transform(Nokogiri::XML("#{body}")).to_s end def count_text(svg) Nokogiri::XML(svg) .xpath('//*[local-name()="text" and @text-anchor="end"]') .first .text end def badge_width(svg) Nokogiri::XML(svg).root['width'].to_f end def test_renders_small_count svg = render(alive: 5, dead: 2) assert_equal('5/7', count_text(svg)) refute_includes(svg, '99+') end def test_renders_count_when_above_threshold svg = render(alive: 150, dead: 50) refute_includes(svg, '99+', "badge must show real numbers, got: #{svg}") assert_equal('150/200', count_text(svg)) end def test_renders_large_count svg = render(alive: 1234, dead: 5678) refute_includes(svg, '99+', "badge must show real numbers, got: #{svg}") assert_equal('1234/6912', count_text(svg)) end def test_widens_to_fit_large_numbers [0, 5, 99, 100, 1000, 100_000].each do |alive| svg = render(alive: alive) width = badge_width(svg) text = count_text(svg) # 47 px label area + per-character advance ~6.5 + 7 px right padding min = 47 + (text.length * 6.5) + 7 assert_operator(width, :>=, min, "badge width #{width} too narrow for #{text.inspect} (alive=#{alive})") end end def test_text_anchor_stays_inside_badge [0, 5, 99, 100, 1000, 100_000].each do |alive| svg = render(alive: alive) doc = Nokogiri::XML(svg) width = doc.root['width'].to_f doc.xpath('//*[local-name()="text" and @text-anchor="end"]').each do |t| x = t['x'].to_f assert_operator(x, :<=, width, "anchor x=#{x} outside width=#{width} for alive=#{alive}") assert_operator(x, :>=, 47, "anchor x=#{x} crosses into label area for alive=#{alive}") end end end end ================================================ FILE: test/test_truncated.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require_relative 'test__helper' require_relative '../objects/truncated' # Truncated test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestTruncated < Minitest::Test def test_simple_formatting assert_equal('How...', Truncated.new('How are you?', 7).to_s) end def test_very_long_text assert_equal( 'How are...', Truncated.new('How are you? How are you? How are you? How are you?', 13).to_s ) end def test_short_long_text assert_equal('Hey', Truncated.new('Hey', 13).to_s) end def test_unicode_text assert_equal( 'Как дела?...', Truncated.new("Как дела?\n Как дела? \nКак дела? \n Как дела?\n", 13).to_s ) end def test_multi_line_text assert_equal( 'First line Second...', Truncated.new(" First line \n Second line\nThird line ", 23).to_s ) end end ================================================ FILE: test/test_upgraded_storage.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require_relative 'test__helper' require_relative 'fake_storage' require_relative 'fake_log' require_relative '../objects/storage/safe_storage' require_relative '../objects/storage/upgraded_storage' require_relative '../objects/storage/versioned_storage' # UpgradedStorage test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestUpgradedStorage < Minitest::Test def test_safety_preserved fake = FakeStorage.new fake.save(Nokogiri::XML('')) storage = UpgradedStorage.new( SafeStorage.new(VersionedStorage.new(fake, '0.0.5')), '0.0.5' ) refute_empty(storage.load.xpath('/puzzles')) end def test_removes_broken_issues storage = UpgradedStorage.new(FakeStorage.new, '0.0.1') storage.save( Nokogiri::XML( 'X1123 X2' ) ) refute_empty(storage.load.xpath('//puzzle[id="X1"]/issue')) assert_empty(storage.load.xpath('//puzzle[id="X2"]/issue')) end def test_removes_broken_href storage = UpgradedStorage.new(FakeStorage.new, '0.0.2') storage.save( Nokogiri::XML( 'X1123 X2123' ) ) refute_empty(storage.load.xpath('//puzzle[id="X1"]/issue/@href')) assert_empty(storage.load.xpath('//puzzle[id="X2"]/issue/@href')) end end ================================================ FILE: test/test_versioned_storage.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT require 'nokogiri' require_relative 'test__helper' require_relative 'fake_storage' require_relative 'fake_log' require_relative '../objects/storage/versioned_storage' # VersionedStorage test. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko # License:: MIT class TestVersionedStorage < Minitest::Test def test_xml_versioning version = '0.0.1' storage = VersionedStorage.new(FakeStorage.new, version) storage.save(Nokogiri::XML('hello')) assert_equal(version, storage.load.xpath('/test/@version')[0].text) end end ================================================ FILE: test-assets/puzzles/closes-one-puzzle.xml ================================================ 100 23 15 IMP TEST-ae347a22 11-18 Some other text of the other puzzle. readme.txt yegor256 yegor@0pdd.com /puzzles[@date and @version] /puzzles[count(//puzzle)=1] /puzzles[count(//puzzle[@alive='true'])=0] /puzzles[count(//puzzle[@alive='false'])=1] //puzzle[id='TEST-ae347a22' and @alive='false'] TEST-ae347a22 ================================================ FILE: test-assets/puzzles/ignores-unknown-issues.xml ================================================ unknown 23 15 IMP 23-ae347a22 11-18 Some other text of the other puzzle. readme.txt yegor256 yegor@0pdd.com /puzzles[@date and @version] ================================================ FILE: test-assets/puzzles/notify-unknown-open-issues.xml ================================================ 1-abcdef 5 5-abcdef unknown 5 ================================================ FILE: test-assets/puzzles/simple.xml ================================================ 516 15 IMP 516-ffc97ad1 61-63 Move header names from A to B. src/test/java/Test.java yegor256 yegor@0pdd.com 100 23 15 IMP 23-ae347a22 11-18 Some other text of the other puzzle. readme.txt yegor256 yegor@0pdd.com 516 23 15 IMP 23-ffc97ad1 12-16 Some text of the puzzle. readme.txt yegor256 yegor@0pdd.com /puzzles[@date] /puzzles[count(//puzzle)=3] /puzzles[count(//puzzle[@alive='true'])=1] //puzzle[id='516-ffc97ad1' and @alive='true'] //puzzle[id='516-ffc97ad1' and issue='123'] //puzzle[id='23-ffc97ad1' and count(children/puzzle)=1] 516-ffc97ad1 23-ae347a22 23-ffc97ad1 ================================================ FILE: test-assets/puzzles/submits-old-puzzles.xml ================================================ 516 60 IMP 516-ffdd7ae8 61-63 Move some files around. src/test/java/Test.java yegor256 yegor@0pdd.com 516 15 IMP 516-eedd7ae8 61-63 Move some files around. src/test/java/Test.java yegor256 yegor@0pdd.com 516 60 IMP 516-ffdd7ae8 61-63 Move some files around. src/test/java/Test.java yegor256 yegor@0pdd.com unknown 516 15 IMP 516-eedd7ae8 61-63 Move some files around. src/test/java/Test.java yegor256 yegor@0pdd.com /puzzles[@date and @version] /puzzles[count(//puzzle)=2] //puzzle[id='516-ffdd7ae8' and issue] //puzzle[id='516-eedd7ae8' and issue!='unknown'] 516-eedd7ae8 516-ffdd7ae8 ================================================ FILE: test-assets/puzzles/submits-ranked-puzzles.xml ================================================ 516 60 IMP 516-ffdd7ae8 61-63 Move some files around. src/test/java/Test.java yegor256 yegor@0pdd.com 517 15 IMP 517-ffdd7ae9 61-63 Move some files around. src/test/java/Test.java yegor256 yegor@0pdd.com 518 30 IMP 518-ffdd7aea 61-63 Move some files around. src/test/java/Test.java yegor256 yegor@0pdd.com /puzzles[count(//puzzle)=3] 517-ffdd7ae9 518-ffdd7aea 516-ffdd7ae8 ================================================ FILE: test-assets/puzzles/submits-three-tickets.xml ================================================ 516 60 IMP 516-ffdd7ae8 61-63 Move some files around. src/test/java/Test.java yegor256 yegor@0pdd.com 517 15 IMP 517-ffdd7ae9 61-63 Move some files around. src/test/java/Test.java yegor256 yegor@0pdd.com 518 30 IMP 518-ffdd7aea 61-63 Move some files around. src/test/java/Test.java yegor256 yegor@0pdd.com /puzzles[count(//puzzle)=3] 517-ffdd7ae9 518-ffdd7aea 516-ffdd7ae8 ================================================ FILE: version.rb ================================================ # SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko # SPDX-License-Identifier: MIT VERSION = 'BUILD'.freeze ================================================ FILE: views/_footer.haml ================================================ -# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko -# SPDX-License-Identifier: MIT %p ='--' %p =ver ================================================ FILE: views/_header.haml ================================================ -# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko -# SPDX-License-Identifier: MIT %p %a{href:'/'} %img.logo{src:'https://avatars2.githubusercontent.com/u/24456188'} %p - if defined? user = "@#{user[:login]}" = '|' %a{href: '/logout'} Logout - else %a{href: login_link} Login %p %a{href: "/p?name=#{repo}"} = repo ================================================ FILE: views/error.haml ================================================ -# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko -# SPDX-License-Identifier: MIT %p %a{href:'/'} %img.logo{src:'https://avatars2.githubusercontent.com/u/24456188'} %p{style:'color: red;'} Internal error at =ver %p Please help us improve our service and submit the following stacktrace to our issue tracker in GitHub: %a{href:'https://github.com/yegor256/0pdd'} yegor256/0pdd %pre &=error ================================================ FILE: views/error_400.haml ================================================ -# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko -# SPDX-License-Identifier: MIT %p Request error %pre = error_message ================================================ FILE: views/index.haml ================================================ -# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko -# SPDX-License-Identifier: MIT %div.center %p %a{href:url('/')} %img.logo{src:'/images/logo.svg'} %p PDD puzzles collector. %p %a{href:'http://www.yegor256.com/2017/04/05/pdd-in-action.html'} How does it work? - if !tail.empty? %p Recent commits: %br - tail.each_with_index do |r| %span %a{href:'https://github.com/' + r}>= r %p Made by %a{href:'https://www.yegor256.com'} @yegor256 for = succeed "." do %a{href:'https://www.zerocracy.com'} Zerocracy %p %a{href:'https://www.sixnines.io/h/574a'} %img{src:'https://www.sixnines.io/b/574a'}> %a{href:'https://www.rehttp.net/i?u=http%3A%2F%2Fwww.0pdd.com%2Fhook%2Fgithub'} %img{src:'https://www.rehttp.net/b?u=http%3A%2F%2Fwww.0pdd.com%2Fhook%2Fgithub'}> %p %a{href:'https://github.com/yegor256/0pdd'} %img{src:'https://img.shields.io/github/stars/yegor256/0pdd.svg'} %p.versions %img{src:url('/images/logo.svg'), alt:'Currently deployed version'} %span{title:'Currently deployed version'} = ver %img{src:url('/images/git-logo.svg'), alt:'Git version on the server'} %span{title:'Git version on the server'} = git_version %img{src:url('/images/ruby-logo.svg'), alt:'Ruby version on the server'} %span{title:'Ruby version on the server'} = ruby_version %p %span{style: 'color:' + (remaining < 1000 ? 'red' : 'green'), title: 'GitHub API remaining quota'}= remaining ================================================ FILE: views/item.haml ================================================ -# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko -# SPDX-License-Identifier: MIT = Haml::Engine.new(File.read('views/_header.haml')).render(Object.new, local_assigns) %p This is a single entry in the log. All entries have unique mnemo "tags", in order to prevent duplicate events from happening. %p Repository: = repo %p Tag: = item['tag'] %p Time: = Time.at(item['time']).iso8601 %p Details: = item['text'] - if defined?(user) && user[:login] == 'yegor256' %p %a{href:url("/log-delete?name=#{repo}&time=#{item['time'].to_i}&tag=#{item['tag']}"), onclick: "return confirm('You are going to delete the \"#{item['tag']}\" event. Normally you should not do this. Are you sure?')"} delete it = Haml::Engine.new(File.read('views/_footer.haml')).render(Object.new, local_assigns) ================================================ FILE: views/layout.haml ================================================ -# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko -# SPDX-License-Identifier: MIT !!! 5 %html %head %title =title %meta{charset:'UTF-8'} %meta{name:'viewport', content:'width=device-width, initial-scale=1.0'} %meta{name:'keywords', content:'PDD, GitHub, Puzzle Driven Development'} %meta{name:'description', content:'Hosted PDD puzzles collector'} %link{type:'text/css', href:url('css/main.css'), rel:'stylesheet'} %link{rel:'shortcut icon', href:'https://avatars2.githubusercontent.com/u/24456188'} %body = yield :javascript (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), m=s.getElementsByTagName(o)[0].text;a.async=1;a.src=g;m.parentNode.insertBefore(a,m) })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); ga('create', 'UA-1963507-49', 'auto'); ga('send', 'pageview'); ================================================ FILE: views/log.haml ================================================ -# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko -# SPDX-License-Identifier: MIT = Haml::Engine.new(File.read('views/_header.haml')).render(Object.new, local_assigns) %p This log is here for safety reason. Every time 0pdd is touching your GitHub repository, this log gets a unique record. Later, if 0pdd will try to submit a similar issue by mistake, this log will prevent that from happening, because all records here must be unique. %p Since = Time.at(since).iso8601 - log_list = log.list(since) - events = log_list.items - if events.empty? %p There are no events yet. - else - next_page = nil - events.each do |e| - next_page = e['time'].to_i %p %a{href:url("/log-item?repo=#{repo}&tag=#{e['tag']}")}>= Time.at(e['time']).iso8601 = ':' = e['text'] - if defined?(user) && user[:login] == 'yegor256' %a{href:url("/log-delete?name=#{repo}&time=#{e['time'].to_i}&tag=#{e['tag']}"), onclick: "return confirm('You are going to delete the \"#{e['tag']}\" event. Normally you should not do this. Are you sure?')"} delete - unless log_list.last_evaluated_key.nil? %p %a{href:url("/log?name=#{repo}&since=#{next_page}")} = 'more' = Haml::Engine.new(File.read('views/_footer.haml')).render(Object.new, local_assigns) ================================================ FILE: views/not_found.haml ================================================ -# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko -# SPDX-License-Identifier: MIT %p This page is not here. Most likely the link is broken or expired.