Full Code of yegor256/0pdd for AI

master 297730b34683 cached
152 files
211.3 KB
66.6k tokens
476 symbols
1 requests
Download .txt
Showing preview only (242K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
 * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
 * SPDX-License-Identifier: MIT
-->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" version="1.0" exclude-result-prefixes="xs">
  <xsl:output method="xml"/>
  <xsl:strip-space elements="*"/>
  <xsl:template match="/puzzles">
    <puzzles xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://www.0pdd.com/puzzles.xsd">
      <xsl:apply-templates select="node()|@*"/>
    </puzzles>
  </xsl:template>
  <xsl:template match="node()|@*">
    <xsl:copy>
      <xsl:apply-templates select="node()|@*"/>
    </xsl:copy>
  </xsl:template>
</xsl:stylesheet>


================================================
FILE: assets/upgrades/remove-broken-issues.xsl
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
 * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
 * SPDX-License-Identifier: MIT
-->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" version="1.0" exclude-result-prefixes="xs">
  <xsl:output method="xml"/>
  <xsl:strip-space elements="*"/>
  <xsl:template match="puzzle/issue[string-length(.) = 0]">
    <!-- This issue is broken, we just don't copy it -->
  </xsl:template>
  <xsl:template match="puzzle/issue/@href[string-length(.) = 0]">
    <!-- This HREF is broken, we just don't copy it -->
  </xsl:template>
  <xsl:template match="node()|@*">
    <xsl:copy>
      <xsl:apply-templates select="node()|@*"/>
    </xsl:copy>
  </xsl:template>
</xsl:stylesheet>


================================================
FILE: assets/xsd/puzzles.xsd
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
 * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
 * SPDX-License-Identifier: MIT
-->
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
  <xs:simpleType name="issue_name">
    <xs:restriction base="xs:string">
      <xs:pattern value="[0-9]+|[A-Z]+-[0-9]+|unknown"/>
    </xs:restriction>
  </xs:simpleType>
  <xs:complexType name="puzzle">
    <xs:all>
      <xs:element name="id" minOccurs="1" maxOccurs="1">
        <xs:simpleType>
          <xs:restriction base="xs:string">
            <xs:pattern value="[a-zA-Z0-9\-]+-[a-f0-9]{8}"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:element>
      <xs:element name="issue" minOccurs="0" maxOccurs="1">
        <xs:complexType>
          <xs:simpleContent>
            <xs:extension base="issue_name">
              <xs:attribute name="model" type="xs:integer" use="optional"/>
              <xs:attribute name="href" type="xs:anyURI" use="optional"/>
              <xs:attribute name="closed" use="optional" type="xs:dateTime"/>
            </xs:extension>
          </xs:simpleContent>
        </xs:complexType>
      </xs:element>
      <xs:element name="body" minOccurs="1" maxOccurs="1">
        <xs:simpleType>
          <xs:restriction base="xs:string">
            <xs:minLength value="1"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:element>
      <xs:element name="lines" minOccurs="1" maxOccurs="1">
        <xs:simpleType>
          <xs:restriction base="xs:string">
            <xs:pattern value="[0-9]+-[0-9]+"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:element>
      <xs:element name="file" minOccurs="1" maxOccurs="1">
        <xs:simpleType>
          <xs:restriction base="xs:string">
            <xs:pattern value=".+"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:element>
      <xs:element name="estimate" minOccurs="1" maxOccurs="1">
        <xs:simpleType>
          <xs:restriction base="xs:integer">
            <xs:minInclusive value="0"/>
            <xs:maxInclusive value="60000"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:element>
      <xs:element name="ticket" minOccurs="1" maxOccurs="1">
        <xs:simpleType>
          <xs:restriction base="xs:string">
            <xs:pattern value="[a-zA-Z0-9\-]+"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:element>
      <xs:element name="role" minOccurs="1" maxOccurs="1">
        <xs:simpleType>
          <xs:restriction base="xs:string">
            <xs:pattern value="[A-Z]+"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:element>
      <xs:element name="author" minOccurs="1" maxOccurs="1">
        <xs:simpleType>
          <xs:restriction base="xs:string">
            <xs:pattern value=".+"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:element>
      <xs:element name="email" minOccurs="1" maxOccurs="1">
        <xs:simpleType>
          <xs:restriction base="xs:string">
            <xs:pattern value="([0-9a-zA-Z]([-_.\w]*[0-9a-zA-Z])*@([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9})"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:element>
      <xs:element name="time" minOccurs="1" maxOccurs="1" type="xs:dateTime"/>
      <xs:element name="children" minOccurs="0" maxOccurs="1">
        <xs:complexType>
          <xs:sequence>
            <xs:element name="puzzle" type="puzzle" minOccurs="0" maxOccurs="unbounded"/>
          </xs:sequence>
        </xs:complexType>
      </xs:element>
    </xs:all>
    <xs:attribute name="alive" use="required" type="xs:boolean"/>
  </xs:complexType>
  <xs:element name="puzzles">
    <xs:complexType>
      <xs:sequence>
        <xs:element name="puzzle" type="puzzle" minOccurs="0" maxOccurs="unbounded"/>
      </xs:sequence>
      <xs:attribute name="date" use="required" type="xs:dateTime"/>
      <xs:attribute name="model" use="optional" type="xs:boolean"/>
      <xs:attribute name="version" use="required">
        <xs:simpleType>
          <xs:restriction base="xs:string">
            <xs:pattern value="[0-9\.]+|BUILD"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:attribute>
    </xs:complexType>
    <xs:unique name="puzzleId">
      <xs:selector xpath=".//puzzle"/>
      <xs:field xpath="@id"/>
    </xs:unique>
  </xs:element>
</xs:schema>


================================================
FILE: assets/xsl/group.xsl
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
 * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
 * SPDX-License-Identifier: MIT
-->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" version="1.0" exclude-result-prefixes="xs">
  <xsl:output method="xml"/>
  <xsl:strip-space elements="*"/>
  <xsl:key name="issues" match="//puzzle" use="issue"/>
  <xsl:key name="roots" match="//puzzle[not(key('issues',ticket))]" use="id"/>
  <xsl:template match="/puzzles">
    <xsl:copy>
      <xsl:apply-templates select="@*"/>
      <xsl:apply-templates select="//puzzle[key('roots',id)]"/>
    </xsl:copy>
  </xsl:template>
  <xsl:template match="puzzle">
    <xsl:copy>
      <xsl:apply-templates select="node()|@*"/>
      <children>
        <xsl:apply-templates select="//puzzle[ticket=current()/issue]"/>
      </children>
    </xsl:copy>
  </xsl:template>
  <xsl:template match="node()|@*">
    <xsl:copy>
      <xsl:apply-templates select="node()|@*"/>
    </xsl:copy>
  </xsl:template>
</xsl:stylesheet>


================================================
FILE: assets/xsl/join.xsl
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
 * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
 * SPDX-License-Identifier: MIT
-->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" version="1.0" exclude-result-prefixes="xs">
  <xsl:output method="xml"/>
  <xsl:strip-space elements="*"/>
  <xsl:key name="existing" match="//puzzle" use="id"/>
  <xsl:key name="extras" match="//extra" use="id"/>
  <xsl:template match="/puzzles">
    <xsl:copy>
      <xsl:apply-templates select="@*"/>
      <xsl:apply-templates select="//puzzle[id!='unknown']"/>
      <xsl:apply-templates select="//extra[not(key('existing',id))]"/>
    </xsl:copy>
  </xsl:template>
  <xsl:template match="puzzle|extra">
    <puzzle>
      <xsl:attribute name="alive">
        <xsl:choose>
          <xsl:when test="key('extras',id)">
            <xsl:text>true</xsl:text>
          </xsl:when>
          <xsl:otherwise>
            <xsl:text>false</xsl:text>
          </xsl:otherwise>
        </xsl:choose>
      </xsl:attribute>
      <xsl:choose>
        <xsl:when test="issue">
          <xsl:apply-templates select="issue"/>
        </xsl:when>
        <xsl:otherwise>
          <issue>
            <xsl:text>unknown</xsl:text>
          </issue>
        </xsl:otherwise>
      </xsl:choose>
      <xsl:apply-templates select="ticket|estimate|role|id|lines|body|file|author|email|time"/>
    </puzzle>
  </xsl:template>
  <xsl:template match="node()|@*">
    <xsl:copy>
      <xsl:apply-templates select="node()|@*"/>
    </xsl:copy>
  </xsl:template>
</xsl:stylesheet>


================================================
FILE: assets/xsl/puzzles.xsl
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
 * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
 * SPDX-License-Identifier: MIT
-->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns="http://www.w3.org/1999/xhtml" version="1.0">
  <xsl:output method="xml" omit-xml-declaration="yes"/>
  <xsl:param name="version"/>
  <xsl:param name="project"/>
  <xsl:param name="length"/>
  <xsl:template match="/puzzles">
    <html>
      <head>
        <meta charset="UTF-8"/>
        <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
        <meta name="description" content="{$project}"/>
        <meta name="keywords" content="{$project}"/>
        <meta name="author" content="0pdd.com"/>
        <title>
          <xsl:value-of select="$project"/>
        </title>
        <link type="text/css" href="/css/main.css" rel="stylesheet"/>
        <link rel="shortcut icon" href="https://avatars2.githubusercontent.com/u/24456188"/>
      </head>
      <body>
        <p>
          <a href="https://www.0pdd.com">
            <img class="logo" src="https://avatars2.githubusercontent.com/u/24456188"/>
          </a>
        </p>
        <p>
          <img src="/svg?name={$project}"/>
        </p>
        <p>
          <xsl:value-of select="count(//puzzle[@alive='true'])"/>
          <xsl:text> alive, </xsl:text>
          <xsl:value-of select="count(//puzzle)"/>
          <xsl:text> total.</xsl:text>
        </p>
        <xsl:apply-templates select="puzzle"/>
        <p>
          <xsl:text>--</xsl:text>
        </p>
        <p>
          <xsl:text>Full </xsl:text>
          <a href="/log?name={$project}">
            <xsl:text>log</xsl:text>
          </a>
          <xsl:text> of recent events.</xsl:text>
        </p>
        <p>
          <xsl:text>Download </xsl:text>
          <a href="/xml?name={$project}">
            <xsl:text>XML</xsl:text>
          </a>
          <xsl:text> (</xsl:text>
          <span title="{$length} bytes">
            <xsl:value-of select="format-number($length div 1024, '#.0')"/>
            <xsl:text> Kb</xsl:text>
          </span>
          <xsl:text>); see </xsl:text>
          <a href="/snapshot?name={$project}">
            <xsl:text>snapshot</xsl:text>
          </a>
          <xsl:text>.</xsl:text>
        </p>
        <p>
          <xsl:text>Project "</xsl:text>
          <xsl:value-of select="$project"/>
          <xsl:text>" updated by </xsl:text>
          <a href="https://www.0pdd.com">
            <xsl:text>0pdd</xsl:text>
          </a>
          <xsl:text> v</xsl:text>
          <xsl:value-of select="@version"/>
          <xsl:text> on </xsl:text>
          <xsl:value-of select="@date"/>
          <xsl:text>.</xsl:text>
        </p>
        <p>
          <a href="https://www.0pdd.com" title="Current version of 0pdd is {$version}">
            <xsl:value-of select="$version"/>
          </a>
        </p>
      </body>
    </html>
  </xsl:template>
  <xsl:template match="puzzle">
    <div>
      <span>
        <xsl:if test="@alive = 'false'">
          <xsl:attribute name="style">
            <xsl:text>color:gray;</xsl:text>
          </xsl:attribute>
        </xsl:if>
        <xsl:apply-templates select="id" mode="fonted"/>
        <xsl:text> </xsl:text>
        <xsl:value-of select="file"/>
        <xsl:text>:</xsl:text>
        <xsl:value-of select="lines"/>
        <xsl:text> </xsl:text>
        <xsl:value-of select="estimate"/>
        <xsl:text>min </xsl:text>
      </span>
      <xsl:if test="children/puzzle">
        <div style="margin-left: 2em;">
          <xsl:apply-templates select="children/puzzle"/>
        </div>
      </xsl:if>
    </div>
  </xsl:template>
  <xsl:template match="id" mode="fonted">
    <xsl:choose>
      <xsl:when test="../@alive='true'">
        <xsl:apply-templates select="." mode="linked"/>
      </xsl:when>
      <xsl:otherwise>
        <strike>
          <xsl:apply-templates select="." mode="linked"/>
        </strike>
      </xsl:otherwise>
    </xsl:choose>
  </xsl:template>
  <xsl:template match="id" mode="linked">
    <xsl:choose>
      <xsl:when test="../issue/@href">
        <a href="{../issue/@href}" style="color:inherit">
          <xsl:value-of select="."/>
        </a>
      </xsl:when>
      <xsl:otherwise>
        <xsl:value-of select="."/>
      </xsl:otherwise>
    </xsl:choose>
  </xsl:template>
</xsl:stylesheet>


================================================
FILE: assets/xsl/svg.xsl
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
 * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
 * SPDX-License-Identifier: MIT
-->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns="http://www.w3.org/2000/svg" version="1.0">
  <xsl:output method="xml" omit-xml-declaration="yes"/>
  <xsl:template match="/puzzles">
    <xsl:variable name="alive" select="count(//puzzle[@alive='true'])"/>
    <xsl:variable name="total" select="count(//puzzle)"/>
    <xsl:variable name="count" select="concat($alive, '/', $total)"/>
    <xsl:variable name="advance" select="47 + (string-length($count) * 6.5) + 7"/>
    <xsl:variable name="width">
      <xsl:choose>
        <xsl:when test="$advance &gt; 86">
          <xsl:value-of select="ceiling($advance)"/>
        </xsl:when>
        <xsl:otherwise>
          <xsl:text>86</xsl:text>
        </xsl:otherwise>
      </xsl:choose>
    </xsl:variable>
    <svg width="{$width}" height="20">
      <linearGradient id="b" x2="0" y2="100%">
        <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
        <stop offset="1" stop-opacity=".1"/>
      </linearGradient>
      <mask id="a">
        <rect width="{$width}" height="20" rx="3" fill="#fff"/>
      </mask>
      <g mask="url(#a)">
        <path fill="#555" d="M0 0h47v20H0z"/>
        <path fill="#4c1" d="M47 0h{$width - 47}v20H47z"/>
        <path fill="url(#b)" d="M0 0h{$width}v20H0z"/>
      </g>
      <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
        <text x="19.5" y="15" fill="#010101" fill-opacity=".3">0pdd</text>
        <text x="19.5" y="14">0pdd</text>
        <text x="{$width - 3.5}" y="15" fill="#010101" fill-opacity=".3" text-anchor="end">
          <xsl:value-of select="$count"/>
        </text>
        <text x="{$width - 3.5}" y="14" text-anchor="end">
          <xsl:value-of select="$count"/>
        </text>
      </g>
    </svg>
  </xsl:template>
</xsl:stylesheet>


================================================
FILE: assets/xsl/to-close.xsl
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
 * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
 * SPDX-License-Identifier: MIT
-->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" version="1.0" exclude-result-prefixes="xs">
  <xsl:output method="xml"/>
  <xsl:strip-space elements="*"/>
  <xsl:key name="extras" match="//extra" use="id"/>
  <xsl:template match="/puzzles">
    <xsl:copy>
      <xsl:apply-templates select="//puzzle[@alive='true' and not(key('extras',id)) and issue!='unknown']"/>
    </xsl:copy>
  </xsl:template>
  <xsl:template match="node()|@*">
    <xsl:copy>
      <xsl:apply-templates select="node()|@*"/>
    </xsl:copy>
  </xsl:template>
</xsl:stylesheet>


================================================
FILE: assets/xsl/to-submit.xsl
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
 * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
 * SPDX-License-Identifier: MIT
-->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" version="1.0" exclude-result-prefixes="xs">
  <xsl:output method="xml"/>
  <xsl:strip-space elements="*"/>
  <xsl:key name="existing" match="//puzzle[@alive='true']" use="id"/>
  <xsl:template match="/puzzles">
    <xsl:copy>
      <xsl:apply-templates select="//extra[not(key('existing',id))]"/>
    </xsl:copy>
  </xsl:template>
  <xsl:template match="extra">
    <puzzle>
      <xsl:apply-templates select="@*|node()"/>
    </puzzle>
  </xsl:template>
  <xsl:template match="node()|@*">
    <xsl:copy>
      <xsl:apply-templates select="node()|@*"/>
    </xsl:copy>
  </xsl:template>
</xsl:stylesheet>


================================================
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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!--
 * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
 * SPDX-License-Identifier: MIT
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.0pdd</groupId>
  <artifactId>dynamodb-local</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>pom</packaging>
  <name>dynamodb-local</name>
  <properties>
    <dynamo.key>AAAAABBBBBAAAAABBBBB</dynamo.key>
    <dynamo.secret>ABCDABCDABCDABCDABCDABCDABCDABCDABCDABCD</dynamo.secret>
  </properties>
  <build>
    <plugins>
      <plugin>
        <artifactId>maven-dependency-plugin</artifactId>
        <executions>
          <execution>
            <id>unpack-dynamodb-local</id>
            <goals>
              <goal>unpack</goal>
            </goals>
            <configuration>
              <artifactItems>
                <artifactItem>
                  <groupId>com.jcabi</groupId>
                  <artifactId>DynamoDBLocal</artifactId>
                  <version>2023-05-26</version>
                  <type>zip</type>
                  <outputDirectory>${project.build.directory}/dynamodb-dist</outputDirectory>
                  <overWrite>false</overWrite>
                </artifactItem>
              </artifactItems>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>build-helper-maven-plugin</artifactId>
        <version>3.6.1</version>
        <executions>
          <execution>
            <id>reserver-dynamodb-port</id>
            <goals>
              <goal>reserve-network-port</goal>
            </goals>
            <configuration>
              <portNames>
                <portName>dynamo.port</portName>
              </portNames>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <artifactId>maven-resources-plugin</artifactId>
        <version>3.5.0</version>
        <executions>
          <execution>
            <id>copy-resources</id>
            <phase>pre-integration-test</phase>
            <goals>
              <goal>copy-resources</goal>
            </goals>
            <configuration>
              <outputDirectory>${project.build.directory}</outputDirectory>
              <resources>
                <resource>
                  <directory>${basedir}/config</directory>
                  <filtering>true</filtering>
                </resource>
              </resources>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>com.jcabi</groupId>
        <artifactId>jcabi-dynamodb-maven-plugin</artifactId>
        <version>0.10.1</version>
        <executions>
          <execution>
            <id>dynamodb-integration-test</id>
            <goals>
              <goal>start</goal>
              <goal>create-tables</goal>
              <goal>wait</goal>
            </goals>
            <configuration>
              <port>${dynamo.port}</port>
              <dist>${project.build.directory}/dynamodb-dist</dist>
              <key>${dynamo.key}</key>
              <secret>${dynamo.secret}</secret>
              <arguments>
                <argument>-inMemory</argument>
              </arguments>
              <tables>
                <table>${basedir}/tables/0pdd-events.json</table>
              </tables>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>


================================================
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://<your_subdomain>.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 <no-reply@0pdd.com>'
        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 "<html><body><p>Hey,</p>
            <p>There is a problem in
            <a href='#{repository_link}'>#{name}</a>:</p>
            <pre>#{trace}</pre>
            <p>If you think it's our bug, please submit it to
            <a href='https://github.com/yegor256/0pdd/issues'>GitHub</a>.
            Thanks.</p>
            <p>Sorry,<br/><a href='https://www.0pdd.com'>0pdd</a></p>"
        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 <user/repo_name>. 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 <puzzle>
  # elements are added as <extra> 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 <extra> elements with <puzzle> 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 href='#{issue[:href]}'>#{issue[:number]}</issue>"
      )
      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}"
        '<puzzles xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://www.0pdd.com/puzzles.xsd"/>'
      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 <no-reply@0pdd.com>'
      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 <no-reply@0pdd.com>'
        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 <no-reply@0pdd.com>'
      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 "<html><body><p>Hi,</p>
        <pre>#{e.message}\n\n#{e.backtrace.join("\n")}</pre>
        </body></html>"
      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('<puzzles date="2016-12-10T16:26:36Z"/>')
  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 = '<puzzles date="2016-12-10T16:26:36Z" version="0.1"/>'
  )
    @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, '<xs:schema')
  end

  def test_renders_log_page
    repo = 'yegor256/0pdd'
    log = Log.new(Dynamo.new.aws, repo)
    log.put('some-tag', 'some text here')
    get("/log?name=#{repo}")
    assert_predicate(last_response, :ok?, last_response.body)
    assert_includes(last_response.body, repo, last_response.body)
    assert_includes(last_response.body, 'some text', last_response.body)
  end

  def test_renders_log_item
    repo = 'yegor256/0pdd'
    log = Log.new(Dynamo.new.aws, repo)
    tag = 'some-tag'
    log.put(tag, 'some text here')
    get("/log-item?repo=#{repo}&tag=#{tag}")
    assert_predicate(last_response, :ok?, last_response.body)
    assert_includes(last_response.body, repo, last_response.body)
    assert_includes(last_response.body, 'some text', last_response.body)
  end

  def test_renders_page_not_found
    get('/the-url-that-is-absent')
    assert_equal(404, last_response.status)
  end

  def test_it_understands_push_from_github
    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/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?('<html') &&
        html.include?('<title>'),
      "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, '<svg ',
      "broken SVG: #{svg}"
    )
  end

  def test_renders_xml_puzzles
    get('/xml?name=yegor256/pdd')
    assert_predicate(last_response, :ok?)
    xml = last_response.body
    assert_includes(
      xml, '<puzzles ',
      "broken XML: #{xml}"
    )
  end

  def test_rejects_invalid_repo_name
    get('/svg?name=yego256/pdd+a')
    refute_predicate(last_response, :ok?)
  end

  def test_not_found
    get('/unknown_path')
    assert_equal(404, last_response.status)
    assert_equal('text/html;charset=utf-8', last_response.content_type)
  end
end


================================================
FILE: test/test__helper.rb
================================================
# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko
# SPDX-License-Identifier: MIT

ENV['RACK_ENV'] = 'test'

require 'simplecov'
require 'simplecov-cobertura'
unless SimpleCov.running || ENV['PICKS']
  SimpleCov.command_name('test')
  SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new(
    [
      SimpleCov::Formatter::HTMLFormatter,
      SimpleCov::Formatter::CoberturaFormatter
    ]
  )
  SimpleCov.minimum_coverage 65
  SimpleCov.minimum_coverage_by_file 10
  SimpleCov.start do
    add_filter 'test/'
    add_filter 'vendor/'
    add_filter 'target/'
    track_files 'lib/**/*.rb'
    track_files '*.rb'
  end
end

require 'minitest/autorun'
require 'minitest/reporters'
Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new]
Minitest.load :minitest_reporter

require 'ostruct'
def object(hash)
  json = hash.to_json
  JSON.parse(json, object_class: OpenStruct)
end


================================================
FILE: test/test_cached_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/cached_storage'

# CachedStorage test.
# Author:: Yegor Bugayenko (yegor256@gmail.com)
# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko
# License:: MIT
class TestCachedStorage < Minitest::Test
  def test_simple_xml_loading
    Dir.mktmpdir do |dir|
      storage = CachedStorage.new(FakeStorage.new, File.join(dir, 'a/b/z.xml'))
      storage.save(Nokogiri::XML('<test>hello</test>'))
      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(
        '<puzzle><id>AA</id><issue>1</issue></puzzle>'
      ).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 <no-reply@0pdd.com>'
      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('<puzzles/>'),
      Nokogiri::XML(
        '<puzzles>
          <puzzle alive="true">
            <id>1-abcdef</id>
            <issue>5</issue>
            <children>
              <puzzle alive="true">
                <id>5-abcdef</id>
                <issue href="#">6</issue>
                <ticket>5</ticket>
                <children>
                </children>
              </puzzle>
            </children>
          </puzzle>
        </puzzles>'
      )
    ).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('<puzzles/>'), 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('<puzzles/>'),
      Nokogiri::XML(
        '<puzzles>
          <puzzle alive="true">
            <id>1-abcdef</id>
            <issue>55</issue>
            <children>
              <puzzle alive="true">
                <id>5-abcdee</id>
                <issue href="#">66</issue>
                <ticket>55</ticket>
                <children>
                </children>
              </puzzle>
              <puzzle alive="true">
                <id>5-abcded</id>
                <issue href="#">77</issue>
                <ticket>55</ticket>
                <children>
                </children>
              </puzzle>
            </children>
          </puzzle>
        </puzzles>'
      )
    ).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(
      '<puzzles>
        <puzzle alive="true">
          <id>100-ffffff</id>
          <issue>100</issue>
          <ticket>500</ticket>
        </puzzle>
      </puzzles>'
    )
    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(
      '<puzzles>
        <puzzle alive="true">
          <id>100-1</id>
          <issue>100</issue>
          <ticket>999</ticket>
        </puzzle>
        <puzzle alive="false">
          <id>100-2</id>
          <issue>101</issue>
          <ticket>999</ticket>
          <children>
            <puzzle alive="true">
              <id>101-1</id>
              <issue>13</issue>
              <ticket>101</ticket>
            </puzzle>
          </children>
        </puzzle>
      </puzzles>'
    )
    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(
      '<puzzles>
        <puzzle alive="true">
          <id>1-abcdef</id>
          <issue>5</issue>
          <children>
            <puzzle alive="true">
              <id>5-abcdef</id>
              <issue href="#">6</issue>
              <ticket>5</ticket>
            </puzzle>
          </children>
        </puzzle>
      </puzzles>'
    )
    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 = '<puzzles>
      <puzzle alive="true">
        <id>1-abcdef</id>
        <issue>50</issue>
        <children>
          <puzzle alive="true">
            <id>50-abcdef</id>
            <issue href="#">60</issue>
            <children>
            </children>
          </puzzle>
        </children>
      </puzzle>
    </puzzles>'
    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(
      '<puzzles>
        <puzzle alive="true">
          <id>100-1</id>
          <issue>100</issue>
          <ticket>999</ticket>
        </puzzle>
        <puzzle alive="true">
          <id>100-2</id>
          <issue>101</issue>
          <ticket>999</ticket>
          <children>
            <puzzle alive="true">
              <id>101-1</id>
              <issue>13</issue>
              <ticket>101</ticket>
            </puzzle>
          </children>
        </puzzle>
      </puzzles>'
    )
    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(
        '<puzzle>
          <id>23-ab536de</id>
          <file>/a/b/c/test.txt</file>
          <time>01-01-2019</time>
          <author>yegor</author>
          <body>привет дорогой друг, как твои дела?</body>
          <ticket>123</ticket>
          <estimate>30</estimate>
          <role>DEV</role>
          <lines>1-3</lines>
        </puzzle>'
      ).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(
        '<puzzle>
          <id>55-ab536de</id>
          <file>/a/bz.txt</file>
          <time>01-05-2019</time>
          <author>yegor</author>
          <body>как дела? hey, how are you, please see this title!</body>
          <ticket>123</ticket>
          <estimate>30</estimate>
          <role>DEV</role>
          <lines>1-3</lines>
        </puzzle>'
      ).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(
        '<puzzle>
          <id>55-ab536de</id>
          <file>/a/bz.txt</file>
          <time>01-05-2019</time>
          <author>yegor</author>
          <body>как дела? hey, how are you, please see this title!</body>
          <ticket>123</ticket>
          <estimate>10</estimate>
          <role>DEV</role>
          <lines>1-3</lines>
        </puzzle>'
      ).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(
        '<puzzle>
          <id>55-ab536de</id>
          <file>/a/bz.txt</file>
          <time>01-05-2019</time>
          <author>yegor</author>
          <body>как дела? hey, how are you, please see this title!</body>
          <ticket>123</ticket>
          <estimate>0</estimate>
          <role>DEV</role>
          <lines>1-3</lines>
        </puzzle>'
      ).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(
        '<puzzle><id>xx</id><issue>1</issue></puzzle>'
      ).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('<test>hello</test>'))
    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(
        '<puzzle>
          <id>23-ab536de</id>
          <file>/a/b/c/test.txt</file>
          <body>hey!</body>
          <lines>1-3</lines>
        </puzzle>'
      ).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(
        '<puzzle>
          <id>23-ab536fe</id>
          <issue>1</issue>
        <
Download .txt
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
Download .txt
SYMBOL INDEX (476 symbols across 84 files)

FILE: 0pdd.rb
  function repo_name (line 444) | def repo_name(name)
  function vcs_name (line 450) | def vcs_name(name)
  function merged (line 455) | def merged(hash)
  function storage (line 461) | def storage(repo, vcs)
  function process_request (line 494) | def process_request(vcs)

FILE: model/fake_weights_storage.rb
  class FakeWeightsStorage (line 7) | class FakeWeightsStorage
    method initialize (line 8) | def initialize(
    method load (line 15) | def load
    method save (line 21) | def save(weights)

FILE: model/linear.rb
  class LinearModel (line 17) | class LinearModel
    method initialize (line 18) | def initialize(repo, storage)
    method predict (line 44) | def predict(puzzles)
    method replace_nil (line 65) | def replace_nil(arr, with = 0)
    method get_features_labels (line 69) | def get_features_labels(samples)
    method extract_features (line 88) | def extract_features(puzzles, samples = {}, level = 1)
    method train (line 119) | def train(clf)
    method naive_rank (line 136) | def naive_rank(puzzles)

FILE: model/predictor.rb
  function argsort (line 6) | def argsort(arr)
  function normalised_kendall_tau_distance (line 10) | def normalised_kendall_tau_distance(a, b)
  function default_option_generator_linear (line 24) | def default_option_generator_linear(attribute_num)
  class Predictor (line 34) | class Predictor
    method initialize (line 35) | def initialize(**options)
    method f (line 43) | def f(weights, **options)
    method train (line 57) | def train(weights, data, true_order)
    method predict (line 62) | def predict(weights, data)
    method forward_one (line 72) | def forward_one(weights, data)
    method kendall (line 79) | def kendall(weights, data, true_order)

FILE: model/pso/lib/function.rb
  type Pso (line 6) | module Pso
    class Function (line 10) | class Function
      method f (line 11) | def f(vector, **_options)

FILE: model/pso/lib/functions/rastrigin.rb
  type Pso (line 7) | module Pso
    class Rastrigin (line 11) | class Rastrigin < Pso::Function
      method f (line 12) | def f(vector, **_options)

FILE: model/pso/lib/functions/schwefel.rb
  type Pso (line 7) | module Pso
    class Schwefel (line 11) | class Schwefel < Pso::Function
      method f (line 12) | def f(vector, **_options)

FILE: model/pso/lib/solver.rb
  type Pso (line 8) | module Pso
    class Solver (line 12) | class Solver
      method initialize (line 13) | def initialize(
      method generate_swarm (line 37) | def generate_swarm
      method generate_random_noise_particle (line 44) | def generate_random_noise_particle
      method generate_random_particle (line 48) | def generate_random_particle
      method perfect_particle (line 52) | def perfect_particle
      method solve (line 64) | def solve(precision: 100, threads: 1, debug: false)
      method best? (line 90) | def best?(best, now)
      method normalize (line 98) | def normalize(vector)
      method iterate (line 103) | def iterate(vector, best, perfect, speed)

FILE: model/pso/lib/version.rb
  type Pso (line 4) | module Pso

FILE: model/pso/lib/zero_vector.rb
  class ZeroVector (line 9) | class ZeroVector < Vector
    method normalize (line 10) | def normalize

FILE: model/pso/pso.rb
  type Pso (line 10) | module Pso

FILE: model/storage.rb
  class Storage (line 11) | class Storage
    method initialize (line 12) | def initialize(ocket, bucket, region, key, secret)
    method load (line 19) | def load
    method save (line 28) | def save(weights)

FILE: objects/clients/github.rb
  class Github (line 10) | class Github
    method initialize (line 11) | def initialize(config = {})
    method client (line 15) | def client

FILE: objects/clients/gitlab.rb
  class GitlabClient (line 10) | class GitlabClient
    method initialize (line 11) | def initialize(config = {})
    method client (line 15) | def client

FILE: objects/clients/jira.rb
  class JiraClient (line 11) | class JiraClient
    method initialize (line 12) | def initialize(config = {})
    method client (line 16) | def client

FILE: objects/diff.rb
  class Diff (line 9) | class Diff
    method initialize (line 10) | def initialize(before, after)
    method notify (line 15) | def notify(tickets)
    method issues (line 27) | def issues(xml, *xpath)
    method summary (line 41) | def summary(xml, ticket)

FILE: objects/dynamo.rb
  class Dynamo (line 10) | class Dynamo
    method initialize (line 11) | def initialize(config = {})
    method aws (line 15) | def aws

FILE: objects/git_repo.rb
  class GitRepo (line 17) | class GitRepo
    method initialize (line 20) | def initialize(
    method lock (line 38) | def lock
    method config (line 42) | def config
    method xml (line 51) | def xml
    method push (line 63) | def push
    method change_in_master? (line 71) | def change_in_master?
    method clone (line 77) | def clone
    method pull (line 83) | def pull
    method prepare_key (line 101) | def prepare_key
    method prepare_git (line 116) | def prepare_git

FILE: objects/invitations/github_invitations.rb
  class GithubInvitations (line 7) | class GithubInvitations
    method initialize (line 8) | def initialize(github)
    method accept (line 12) | def accept
    method accept_single_invitation (line 19) | def accept_single_invitation(repo)
    method accept_orgs (line 27) | def accept_orgs

FILE: objects/invitations/github_organization_invitations.rb
  class GithubOrganizationInvitations (line 9) | class GithubOrganizationInvitations
    method initialize (line 10) | def initialize(github)
    method all (line 14) | def all

FILE: objects/jobs/job.rb
  class Job (line 11) | class Job
    method initialize (line 12) | def initialize(vcs, storage, tickets)
    method proceed (line 18) | def proceed
    method opts (line 28) | def opts

FILE: objects/jobs/job_commiterrors.rb
  class JobCommitErrors (line 9) | class JobCommitErrors
    method initialize (line 10) | def initialize(vcs, job)
    method proceed (line 15) | def proceed

FILE: objects/jobs/job_detached.rb
  class JobDetached (line 9) | class JobDetached
    method initialize (line 10) | def initialize(vcs, job)
    method proceed (line 15) | def proceed
    method exclusive (line 25) | def exclusive

FILE: objects/jobs/job_emailed.rb
  class JobEmailed (line 9) | class JobEmailed
    method initialize (line 10) | def initialize(vcs, job)
    method proceed (line 15) | def proceed
    method repo_user_login (line 62) | def repo_user_login
    method user_email (line 66) | def user_email(username)

FILE: objects/jobs/job_recorded.rb
  class JobRecorded (line 7) | class JobRecorded
    method initialize (line 8) | def initialize(vcs, job)
    method proceed (line 13) | def proceed

FILE: objects/jobs/job_starred.rb
  class JobStarred (line 8) | class JobStarred
    method initialize (line 9) | def initialize(vcs, job)
    method proceed (line 14) | def proceed

FILE: objects/log.rb
  class Log (line 13) | class Log
    method initialize (line 14) | def initialize(dynamo, repo, vcs = 'github')
    method put (line 24) | def put(tag, text)
    method get (line 37) | def get(tag)
    method exists (line 51) | def exists(tag)
    method delete (line 65) | def delete(time, tag)
    method list (line 79) | def list(since = Time.now.to_i)

FILE: objects/maybe_text.rb
  class MaybeText (line 7) | class MaybeText
    method initialize (line 8) | def initialize(text_if_present, maybe, exclude_if: false)
    method to_s (line 14) | def to_s

FILE: objects/puzzles.rb
  class Puzzles (line 17) | class Puzzles
    method initialize (line 18) | def initialize(repo, storage)
    method deploy (line 28) | def deploy(tickets)
    method save (line 38) | def save(xml)
    method join (line 47) | def join(before, snapshot)
    method group (line 60) | def group(xml)
    method expose (line 68) | def expose(xml, tickets)

FILE: objects/storage/cached_storage.rb
  class CachedStorage (line 7) | class CachedStorage
    method initialize (line 8) | def initialize(origin, file)
    method load (line 13) | def load
    method save (line 28) | def save(xml)
    method write (line 36) | def write(xml)

FILE: objects/storage/logged_storage.rb
  class LoggedStorage (line 7) | class LoggedStorage
    method initialize (line 8) | def initialize(origin, log)
    method load (line 13) | def load
    method save (line 17) | def save(xml)

FILE: objects/storage/once_storage.rb
  class OnceStorage (line 7) | class OnceStorage
    method initialize (line 8) | def initialize(origin)
    method load (line 12) | def load
    method save (line 16) | def save(xml)

FILE: objects/storage/s3.rb
  class S3 (line 11) | class S3
    method initialize (line 12) | def initialize(ocket, bucket, region, key, secret)
    method load (line 19) | def load
    method save (line 33) | def save(xml)

FILE: objects/storage/safe_storage.rb
  class SafeStorage (line 9) | class SafeStorage
    method initialize (line 10) | def initialize(origin)
    method load (line 15) | def load
    method save (line 19) | def save(xml)
    method valid (line 25) | def valid(xml)

FILE: objects/storage/sync_storage.rb
  class SyncStorage (line 7) | class SyncStorage
    method initialize (line 8) | def initialize(origin)
    method load (line 13) | def load
    method save (line 17) | def save(xml)

FILE: objects/storage/upgraded_storage.rb
  class UpgradedStorage (line 7) | class UpgradedStorage
    method initialize (line 8) | def initialize(origin, version)
    method load (line 13) | def load
    method save (line 26) | def save(xml)

FILE: objects/storage/versioned_storage.rb
  class VersionedStorage (line 7) | class VersionedStorage
    method initialize (line 8) | def initialize(origin, version)
    method load (line 13) | def load
    method save (line 23) | def save(xml)

FILE: objects/tickets/commit_tickets.rb
  class CommitTickets (line 7) | class CommitTickets
    method initialize (line 8) | def initialize(vcs, tickets)
    method notify (line 14) | def notify(issue, message)
    method submit (line 18) | def submit(puzzle)
    method close (line 33) | def close(puzzle)
    method opts (line 51) | def opts
    method suppressed_repo? (line 56) | def suppressed_repo?

FILE: objects/tickets/emailed_tickets.rb
  class EmailedTickets (line 7) | class EmailedTickets
    method initialize (line 8) | def initialize(vcs, tickets)
    method notify (line 13) | def notify(issue, message)
    method submit (line 17) | def submit(puzzle)
    method close (line 46) | def close(puzzle)

FILE: objects/tickets/logged_tickets.rb
  class LoggedTickets (line 11) | class LoggedTickets
    method initialize (line 12) | def initialize(vcs, log, tickets)
    method notify (line 18) | def notify(issue, message)
    method submit (line 22) | def submit(puzzle)
    method close (line 43) | def close(puzzle)

FILE: objects/tickets/milestone_tickets.rb
  class MilestoneTickets (line 7) | class MilestoneTickets
    method initialize (line 8) | def initialize(vcs, tickets)
    method notify (line 13) | def notify(issue, message)
    method submit (line 17) | def submit(puzzle)
    method close (line 54) | def close(puzzle)

FILE: objects/tickets/sentry_tickets.rb
  class SentryTickets (line 12) | class SentryTickets
    method initialize (line 13) | def initialize(tickets)
    method notify (line 17) | def notify(issue, message)
    method submit (line 27) | def submit(puzzle)
    method close (line 38) | def close(puzzle)
    method email (line 51) | def email(e)

FILE: objects/tickets/tagged_tickets.rb
  class TaggedTickets (line 7) | class TaggedTickets
    method initialize (line 8) | def initialize(vcs, tickets)
    method notify (line 13) | def notify(issue, message)
    method submit (line 17) | def submit(puzzle)
    method close (line 54) | def close(puzzle)

FILE: objects/tickets/tickets.rb
  class Tickets (line 11) | class Tickets
    method initialize (line 12) | def initialize(vcs)
    method notify (line 16) | def notify(issue, message)
    method submit (line 25) | def submit(puzzle)
    method close (line 37) | def close(puzzle)
    method users (line 54) | def users
    method title (line 67) | def title(puzzle)
    method body (line 89) | def body(puzzle)

FILE: objects/truncated.rb
  class Truncated (line 7) | class Truncated
    method initialize (line 8) | def initialize(text, max = 40, tail = '...')
    method to_s (line 14) | def to_s

FILE: objects/user_error.rb
  class UserError (line 7) | class UserError < StandardError

FILE: objects/vcs/github.rb
  class GithubRepo (line 10) | class GithubRepo
    method initialize (line 13) | def initialize(client, json, config = {})
    method exists? (line 25) | def exists?
    method issue (line 35) | def issue(issue_id)
    method close_issue (line 52) | def close_issue(issue_id)
    method create_issue (line 56) | def create_issue(data)
    method update_issue (line 67) | def update_issue(issue_id, data)
    method labels (line 71) | def labels
    method add_label (line 75) | def add_label(label, color)
    method add_labels_to_an_issue (line 79) | def add_labels_to_an_issue(issue_id, labels)
    method add_comment (line 83) | def add_comment(issue_id, comment)
    method create_commit_comment (line 87) | def create_commit_comment(sha, comment)
    method list_commits (line 91) | def list_commits
    method user (line 95) | def user(username)
    method star (line 99) | def star
    method repository_link (line 103) | def repository_link
    method collaborators_link (line 107) | def collaborators_link
    method file_link (line 111) | def file_link(file)
    method puzzle_link_for_commit (line 115) | def puzzle_link_for_commit(sha, file, start, stop)
    method issue_link (line 119) | def issue_link(issue_id)
    method git_repo (line 125) | def git_repo(json, config)

FILE: objects/vcs/gitlab.rb
  class GitlabRepo (line 12) | class GitlabRepo
    method initialize (line 15) | def initialize(client, json, config = {})
    method issue (line 23) | def issue(issue_id)
    method close_issue (line 41) | def close_issue(issue_id)
    method create_issue (line 47) | def create_issue(data)
    method update_issue (line 56) | def update_issue(issue_id, data)
    method labels (line 60) | def labels
    method add_label (line 73) | def add_label(label, color)
    method add_labels_to_an_issue (line 77) | def add_labels_to_an_issue(issue_id, labels)
    method add_comment (line 82) | def add_comment(issue_id, comment)
    method create_commit_comment (line 88) | def create_commit_comment(sha, comment)
    method list_commits (line 97) | def list_commits
    method user (line 107) | def user(username)
    method star (line 116) | def star
    method exists? (line 120) | def exists?
    method repository_link (line 135) | def repository_link
    method collaborators_link (line 139) | def collaborators_link
    method file_link (line 143) | def file_link(file)
    method puzzle_link_for_commit (line 147) | def puzzle_link_for_commit(sha, file, start, stop)
    method issue_link (line 151) | def issue_link(issue_id)
    method git_repo (line 157) | def git_repo(json, config)

FILE: objects/vcs/jira.rb
  class JiraRepo (line 10) | class JiraRepo
    method initialize (line 13) | def initialize(client, json, config = {})
    method issue (line 21) | def issue(issue_id)
    method close_issue (line 25) | def close_issue(issue_id)
    method create_issue (line 38) | def create_issue(data)
    method update_issue (line 50) | def update_issue(issue_id, data)
    method exists? (line 62) | def exists?
    method repository_link (line 70) | def repository_link
    method git_repo (line 76) | def git_repo(json, config)

FILE: test/fake_github.rb
  class FakeGithub (line 4) | class FakeGithub
    method initialize (line 7) | def initialize(options = {})
    method rate_limit (line 39) | def rate_limit
    method update_organization_membership (line 48) | def update_organization_membership(org, options = {})
    method organization_memberships (line 55) | def organization_memberships(options = {})
    method user_repository_invitations (line 63) | def user_repository_invitations(_options = {})
    method accept_repository_invitation (line 67) | def accept_repository_invitation(id, _options = {})
    method repositories (line 74) | def repositories(user = nil, _options = {})
    method issue (line 78) | def issue(_)
    method close_issue (line 92) | def close_issue(_); end
    method create_issue (line 94) | def create_issue(_)
    method update_issue (line 101) | def update_issue(_, _); end
    method labels (line 103) | def labels
    method add_label (line 113) | def add_label(_, _); end
    method add_labels_to_an_issue (line 115) | def add_labels_to_an_issue(_, _); end
    method add_comment (line 117) | def add_comment(_, _); end
    method create_commit_comment (line 119) | def create_commit_comment(_, _, _)
    method list_commits (line 125) | def list_commits
    method user (line 134) | def user(_)
    method star (line 141) | def star; end
    method repository (line 143) | def repository(_ = nil)
    method repository_link (line 149) | def repository_link
    method collaborators_link (line 153) | def collaborators_link
    method file_link (line 157) | def file_link(file)
    method puzzle_link_for_commit (line 161) | def puzzle_link_for_commit(sha, file, start, stop)
    method issue_link (line 165) | def issue_link(issue_id)
    method git_repo (line 171) | def git_repo

FILE: test/fake_gitlab.rb
  class FakeGitlab (line 4) | class FakeGitlab
    method initialize (line 7) | def initialize(options = {})
    method repositories (line 14) | def repositories(user = nil, _options = {})
    method issue (line 18) | def issue(_)
    method close_issue (line 32) | def close_issue(_); end
    method create_issue (line 34) | def create_issue(_)
    method update_issue (line 41) | def update_issue(_, _); end
    method labels (line 43) | def labels
    method add_label (line 53) | def add_label(_, _); end
    method add_labels_to_an_issue (line 55) | def add_labels_to_an_issue(_, _); end
    method add_comment (line 57) | def add_comment(_, _); end
    method create_commit_comment (line 59) | def create_commit_comment(_, _)
    method list_commits (line 65) | def list_commits
    method user (line 74) | def user(_)
    method star (line 81) | def star; end
    method repository (line 83) | def repository(_ = nil)
    method project (line 89) | def project(_ = nil)
    method repository_link (line 95) | def repository_link
    method collaborators_link (line 99) | def collaborators_link
    method file_link (line 103) | def file_link(file)
    method puzzle_link_for_commit (line 107) | def puzzle_link_for_commit(sha, file, start, stop)
    method issue_link (line 111) | def issue_link(issue_id)
    method git_repo (line 117) | def git_repo

FILE: test/fake_log.rb
  class FakeLog (line 4) | class FakeLog
    method exists (line 7) | def exists(_)
    method put (line 11) | def put(tag, text)
    method get (line 16) | def get(_tag); end
    method delete (line 18) | def delete(_time, _tag); end
    method list (line 20) | def list(_since = Time.now.to_i)

FILE: test/fake_repo.rb
  class FakeRepo (line 7) | class FakeRepo
    method initialize (line 10) | def initialize(options = {})
    method lock (line 15) | def lock
    method xml (line 19) | def xml
    method push (line 23) | def push

FILE: test/fake_storage.rb
  class FakeStorage (line 7) | class FakeStorage
    method initialize (line 8) | def initialize(
    method load (line 16) | def load
    method save (line 20) | def save(xml)

FILE: test/fake_tickets.rb
  class FakeTickets (line 4) | class FakeTickets
    method initialize (line 7) | def initialize
    method submit (line 12) | def submit(puzzle)
    method close (line 17) | def close(puzzle)

FILE: test/test_0pdd.rb
  class AppTest (line 8) | class AppTest < Minitest::Test
    method app (line 11) | def app
    method test_renders_version (line 15) | def test_renders_version
    method test_robots_txt (line 20) | def test_robots_txt
    method test_it_renders_home_page (line 25) | def test_it_renders_home_page
    method test_renders_some_pages (line 31) | def test_renders_some_pages
    method test_it_renders_puzzles_xsd (line 45) | def test_it_renders_puzzles_xsd
    method test_renders_log_page (line 51) | def test_renders_log_page
    method test_renders_log_item (line 61) | def test_renders_log_item
    method test_renders_page_not_found (line 72) | def test_renders_page_not_found
    method test_it_understands_push_from_github (line 77) | def test_it_understands_push_from_github
    method test_it_ignores_push_from_github_to_not_master (line 95) | def test_it_ignores_push_from_github_to_not_master
    method test_it_accepts_push_from_github_to_not_default_master (line 114) | def test_it_accepts_push_from_github_to_not_default_master
    method test_it_ignore_push_from_github_to_not_default_master (line 134) | def test_it_ignore_push_from_github_to_not_default_master
    method test_it_understands_push_from_gitlab (line 154) | def test_it_understands_push_from_gitlab
    method test_it_ignores_push_from_gitlab_to_not_master (line 172) | def test_it_ignores_push_from_gitlab_to_not_master
    method test_it_accepts_push_from_gitlab_to_not_default_master (line 191) | def test_it_accepts_push_from_gitlab_to_not_default_master
    method test_it_ignores_push_from_gitlab_to_not_default_master (line 211) | def test_it_ignores_push_from_gitlab_to_not_default_master
    method test_renders_html_puzzles (line 231) | def test_renders_html_puzzles
    method test_snapshots_unavailable_repo (line 242) | def test_snapshots_unavailable_repo
    method test_renders_svg_puzzles (line 247) | def test_renders_svg_puzzles
    method test_renders_xml_puzzles (line 258) | def test_renders_xml_puzzles
    method test_rejects_invalid_repo_name (line 268) | def test_rejects_invalid_repo_name
    method test_not_found (line 273) | def test_not_found

FILE: test/test__helper.rb
  function object (line 33) | def object(hash)

FILE: test/test_cached_storage.rb
  class TestCachedStorage (line 12) | class TestCachedStorage < Minitest::Test
    method test_simple_xml_loading (line 13) | def test_simple_xml_loading

FILE: test/test_commit_tickets.rb
  class TestCommitTickets (line 12) | class TestCommitTickets < Minitest::Test
    method test_submits_tickets (line 13) | def test_submits_tickets
    method test_closes_tickets (line 29) | def test_closes_tickets
    method test_scope_suppressed_repo_should_be_quiet (line 45) | def test_scope_suppressed_repo_should_be_quiet

FILE: test/test_credentials.rb
  class CredentialsTest (line 16) | class CredentialsTest < Minitest::Test
    method test_connects_to_git_via_ssh (line 17) | def test_connects_to_git_via_ssh
    method test_connects_to_aws_dynamo (line 31) | def test_connects_to_aws_dynamo
    method test_connects_to_github (line 41) | def test_connects_to_github
    method test_connects_to_aws_s3 (line 69) | def test_connects_to_aws_s3
    method test_sends_email_via_smtp (line 81) | def test_sends_email_via_smtp
    method config (line 108) | def config

FILE: test/test_diff.rb
  class TestDiff (line 13) | class TestDiff < Minitest::Test
    method test_notification_on_one_new_puzzle (line 14) | def test_notification_on_one_new_puzzle
    method test_notification_unknown_issue (line 46) | def test_notification_unknown_issue
    method test_notification_on_two_new_puzzles (line 62) | def test_notification_on_two_new_puzzles
    method test_notification_on_solved_puzzle (line 101) | def test_notification_on_solved_puzzle
    method test_notification_on_one_solved_puzzle (line 125) | def test_notification_on_one_solved_puzzle
    method test_notification_on_update (line 161) | def test_notification_on_update
    method test_quiet_when_no_changes (line 191) | def test_quiet_when_no_changes
    class Tickets (line 214) | class Tickets
      method initialize (line 217) | def initialize
      method notify (line 221) | def notify(ticket, text)

FILE: test/test_diff_complicated.rb
  class TestDiff (line 10) | class TestDiff < Minitest::Test
    method test_notification_on_parent_solved_with_others_unsolved (line 16) | def test_notification_on_parent_solved_with_others_unsolved
    class Tickets (line 52) | class Tickets
      method initialize (line 55) | def initialize
      method notify (line 59) | def notify(ticket, text)

FILE: test/test_git_repo.rb
  class TestGitRepo (line 13) | class TestGitRepo < Minitest::Test
    method test_clone_and_pull (line 14) | def test_clone_and_pull
    method test_merge_unrelated_histories (line 24) | def test_merge_unrelated_histories
    method test_fail_with_user_error (line 44) | def test_fail_with_user_error
    method test_merge_after_amend (line 66) | def test_merge_after_amend
    method test_merge_after_force_push (line 83) | def test_merge_after_force_push
    method test_merge_after_complete_new_master (line 103) | def test_merge_after_complete_new_master
    method test_doesnt_touch_crlf (line 124) | def test_doesnt_touch_crlf
    method test_push (line 148) | def test_push
    method test_fetch_puzzles (line 158) | def test_fetch_puzzles
    method test_fetch_config (line 167) | def test_fetch_config
    method git (line 184) | def git(dir, subdir = 'repo')

FILE: test/test_github.rb
  class TestGithub (line 11) | class TestGithub < Minitest::Test
    method test_configures_everything_right (line 12) | def test_configures_everything_right

FILE: test/test_github_invitations.rb
  class TestGithubInvitation (line 12) | class TestGithubInvitation < Minitest::Test
    method test_accepts_organization_invitations (line 13) | def test_accepts_organization_invitations
    method test_accepts_repository_invitations (line 37) | def test_accepts_repository_invitations

FILE: test/test_github_tickets.rb
  class TestGithubTickets (line 13) | class TestGithubTickets < Minitest::Test
    method test_submits_tickets (line 14) | def test_submits_tickets
    method test_submits_tickets_log_title (line 61) | def test_submits_tickets_log_title
    method test_output_estimates_when_it_is_not_zero (line 101) | def test_output_estimates_when_it_is_not_zero
    method test_skips_estimate_if_zero (line 138) | def test_skips_estimate_if_zero
    method test_closes_tickets (line 175) | def test_closes_tickets

FILE: test/test_gitlab.rb
  class TestGitlab (line 11) | class TestGitlab < Minitest::Test
    method test_configures_everything_right (line 12) | def test_configures_everything_right

FILE: test/test_job.rb
  class TestJob (line 18) | class TestJob < Minitest::Test
    method test_simple_scenario (line 19) | def test_simple_scenario

FILE: test/test_job_commiterrors.rb
  class TestJobCommitErrors (line 11) | class TestJobCommitErrors < Minitest::Test
    class Stub (line 12) | class Stub
      method initialize (line 15) | def initialize(repo)
      method create_commit_comment (line 20) | def create_commit_comment(_, text)
    method test_timeout_scenario (line 25) | def test_timeout_scenario

FILE: test/test_job_detached.rb
  class TestJobDetached (line 11) | class TestJobDetached < Minitest::Test
    method test_simple_scenario (line 12) | def test_simple_scenario

FILE: test/test_job_emailed.rb
  class TestJobEmailed (line 14) | class TestJobEmailed < Minitest::Test
    method fake_job (line 15) | def fake_job
    method test_simple_scenario (line 19) | def test_simple_scenario
    method test_exception_mail_to_repo_owner_as_cc (line 26) | def test_exception_mail_to_repo_owner_as_cc

FILE: test/test_log.rb
  class TestLog (line 14) | class TestLog < Minitest::Test
    method test_put_and_check (line 15) | def test_put_and_check

FILE: test/test_logged_storage.rb
  class TestLoggedStorage (line 14) | class TestLoggedStorage < Minitest::Test
    method test_simple_xml_saving (line 15) | def test_simple_xml_saving

FILE: test/test_logged_tickets.rb
  class TestLoggedTickets (line 15) | class TestLoggedTickets < Minitest::Test
    method test_submits_tickets (line 16) | def test_submits_tickets
    method test_closes_tickets (line 36) | def test_closes_tickets

FILE: test/test_maybe_text.rb
  class TestMaybeText (line 8) | class TestMaybeText < Minitest::Test
    method test_nil_input_then_blank (line 9) | def test_nil_input_then_blank
    method test_empty_input_then_blank (line 13) | def test_empty_input_then_blank
    method test_excluded_input_then_blank (line 17) | def test_excluded_input_then_blank
    method test_present_input_then_output (line 21) | def test_present_input_then_output
    method test_show_output_when_exclude_if_is_present (line 25) | def test_show_output_when_exclude_if_is_present

FILE: test/test_milestone_tickets.rb
  class TestGithubTickets (line 14) | class TestGithubTickets < Minitest::Test
    method test_sets_milestone (line 15) | def test_sets_milestone
    method test_does_not_set_milestone (line 60) | def test_does_not_set_milestone
    method test_adds_comment (line 102) | def test_adds_comment

FILE: test/test_once_storage.rb
  class TestOnceStorage (line 12) | class TestOnceStorage < Minitest::Test
    method test_never_saves_duplicates (line 13) | def test_never_saves_duplicates
    method test_saves_only_once (line 20) | def test_saves_only_once
    class TestStorage (line 27) | class TestStorage
      method initialize (line 30) | def initialize
      method load (line 34) | def load
      method save (line 38) | def save(_)

FILE: test/test_puzzles.rb
  class TestPuzzles (line 20) | class TestPuzzles < Minitest::Test
    method test_all_xml (line 21) | def test_all_xml
    method test_with_broken_tickets (line 32) | def test_with_broken_tickets
    method test_xml (line 54) | def test_xml(dir, name, ordered: false)

FILE: test/test_safe_storage.rb
  class TestSafeStorage (line 14) | class TestSafeStorage < Minitest::Test
    method test_accepts_valid_xml (line 15) | def test_accepts_valid_xml
    method test_rejects_invalid_xml (line 42) | def test_rejects_invalid_xml

FILE: test/test_sentry_tickets.rb
  class TestSentryTickets (line 12) | class TestSentryTickets < Minitest::Test
    method test_exception_catching_on_submit (line 13) | def test_exception_catching_on_submit
    method test_exception_catching_on_close (line 23) | def test_exception_catching_on_close

FILE: test/test_svg.rb
  class TestSvg (line 8) | class TestSvg < Minitest::Test
    method render (line 11) | def render(alive:, dead: 0)
    method count_text (line 16) | def count_text(svg)
    method badge_width (line 23) | def badge_width(svg)
    method test_renders_small_count (line 27) | def test_renders_small_count
    method test_renders_count_when_above_threshold (line 33) | def test_renders_count_when_above_threshold
    method test_renders_large_count (line 39) | def test_renders_large_count
    method test_widens_to_fit_large_numbers (line 45) | def test_widens_to_fit_large_numbers
    method test_text_anchor_stays_inside_badge (line 57) | def test_text_anchor_stays_inside_badge

FILE: test/test_truncated.rb
  class TestTruncated (line 11) | class TestTruncated < Minitest::Test
    method test_simple_formatting (line 12) | def test_simple_formatting
    method test_very_long_text (line 16) | def test_very_long_text
    method test_short_long_text (line 23) | def test_short_long_text
    method test_unicode_text (line 27) | def test_unicode_text
    method test_multi_line_text (line 34) | def test_multi_line_text

FILE: test/test_upgraded_storage.rb
  class TestUpgradedStorage (line 16) | class TestUpgradedStorage < Minitest::Test
    method test_safety_preserved (line 17) | def test_safety_preserved
    method test_removes_broken_issues (line 27) | def test_removes_broken_issues
    method test_removes_broken_href (line 39) | def test_removes_broken_href

FILE: test/test_versioned_storage.rb
  class TestVersionedStorage (line 14) | class TestVersionedStorage < Minitest::Test
    method test_xml_versioning (line 15) | def test_xml_versioning
Condensed preview — 152 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (234K chars).
[
  {
    "path": ".0pdd.yml",
    "chars": 194,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\nerrors:\n  - yegor25"
  },
  {
    "path": ".gitattributes",
    "chars": 243,
    "preview": "# Check out all text files in UNIX format, with LF as end of line\n# Don't change this file. If you have any ideas about "
  },
  {
    "path": ".github/workflows/actionlint.yml",
    "chars": 675,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/bashate.yml",
    "chars": 581,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/codecov.yml",
    "chars": 781,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/copyrights.yml",
    "chars": 405,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/markdown-lint.yml",
    "chars": 421,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/pdd.yml",
    "chars": 392,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/plantuml.yml",
    "chars": 782,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/rake.yml",
    "chars": 710,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/reuse.yml",
    "chars": 382,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/shellcheck.yml",
    "chars": 404,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/typos.yml",
    "chars": 384,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/xcop.yml",
    "chars": 383,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".github/workflows/yamllint.yml",
    "chars": 394,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": ".gitignore",
    "chars": 120,
    "preview": "*.gem\n*.iml\n.bundle/\n.claude/\n.DS_Store\n.idea/\n.sass-cache/\n.yardoc/\ncoverage/\ndoc/\nnode_modules/\nrdoc/\ntarget/\nvendor/\n"
  },
  {
    "path": ".pdd",
    "chars": 179,
    "preview": "--source=.\n--verbose\n--exclude README.md\n--exclude coverage/**/*\n--exclude assets/**/*\n--exclude model/data/**/*\n--rule "
  },
  {
    "path": ".rubocop.yml",
    "chars": 1246,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\nAllCops:\n  Exclude:"
  },
  {
    "path": ".rultor.yml",
    "chars": 1421,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable "
  },
  {
    "path": "0pdd.rb",
    "chars": 15038,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n$stdout.sync = true\n\nr"
  },
  {
    "path": "Aptfile",
    "chars": 4,
    "preview": "git\n"
  },
  {
    "path": "Gemfile",
    "chars": 1166,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nsource 'https://rubyge"
  },
  {
    "path": "LICENSE.txt",
    "chars": 1083,
    "preview": "(The MIT License)\n\nCopyright (c) 2016-2026 Yegor Bugayenko\n\nPermission is hereby granted, free of charge, to any person "
  },
  {
    "path": "LICENSES/MIT.txt",
    "chars": 1083,
    "preview": "(The MIT License)\n\nCopyright (c) 2016-2026 Yegor Bugayenko\n\nPermission is hereby granted, free of charge, to any person "
  },
  {
    "path": "Procfile",
    "chars": 90,
    "preview": "web: bundle exec rackup config.ru -p $PORT\ncron: curl -s https://www.0pdd.com/ping-github\n"
  },
  {
    "path": "README.md",
    "chars": 9491,
    "preview": "# Puzzle Driven Development (PDD) GitHub Chatbot\n\n[![EO principles respected here](https://www.elegantobjects.org/badge."
  },
  {
    "path": "REUSE.toml",
    "chars": 803,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nversion = 1\n[[annotations]]"
  },
  {
    "path": "Rakefile",
    "chars": 1673,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'rubygems'\nreq"
  },
  {
    "path": "app.json",
    "chars": 255,
    "preview": "{\n  \"healthchecks\": {\n    \"web\": [\n      {\n        \"attempts\": 3,\n        \"description\": \"Checking if the app responds t"
  },
  {
    "path": "assets/sass/main.sass",
    "chars": 464,
    "preview": "// SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n// SPDX-License-Identifier: MIT\n\nbody\n  background-co"
  },
  {
    "path": "assets/upgrades/add-namespace.xsl",
    "chars": 761,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "assets/upgrades/remove-broken-issues.xsl",
    "chars": 788,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "assets/xsd/puzzles.xsd",
    "chars": 4413,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "assets/xsl/group.xsl",
    "chars": 1073,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "assets/xsl/join.xsl",
    "chars": 1620,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "assets/xsl/puzzles.xsl",
    "chars": 4418,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "assets/xsl/svg.xsl",
    "chars": 1990,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "assets/xsl/to-close.xsl",
    "chars": 757,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "assets/xsl/to-submit.xsl",
    "chars": 861,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "config.ru",
    "chars": 162,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire './0pdd'\n\n$std"
  },
  {
    "path": "cucumber.yml",
    "chars": 224,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\ndefault: --format p"
  },
  {
    "path": "deploy.sh",
    "chars": 465,
    "preview": "#!/usr/bin/env bash\n\n# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\ns"
  },
  {
    "path": "dynamodb-local/config/dynamo.yml",
    "chars": 166,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\nport: ${dynamo.port"
  },
  {
    "path": "dynamodb-local/pom.xml",
    "chars": 3756,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "dynamodb-local/tables/0pdd-events.json",
    "chars": 972,
    "preview": "{\n  \"AttributeDefinitions\": [\n    {\n      \"AttributeName\": \"repo\",\n      \"AttributeType\": \"S\"\n    },\n    {\n      \"Attrib"
  },
  {
    "path": "features/step_definitions/steps.rb",
    "chars": 295,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'tmpdir'\nrequi"
  },
  {
    "path": "model/README.md",
    "chars": 1025,
    "preview": "Puzzle Ranking (Linear ML Model)\n\n### Internals\n\nThe ML model is a linear model with PSO optimizer.\nThe optimizer is use"
  },
  {
    "path": "model/fake_weights_storage.rb",
    "chars": 491,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# FakeWeightsStorage"
  },
  {
    "path": "model/linear.rb",
    "chars": 4682,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'json'\nrequire"
  },
  {
    "path": "model/predictor.rb",
    "chars": 1974,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'pso/"
  },
  {
    "path": "model/pso/lib/function.rb",
    "chars": 264,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'matrix'\n\nmodu"
  },
  {
    "path": "model/pso/lib/functions/rastrigin.rb",
    "chars": 409,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative '../f"
  },
  {
    "path": "model/pso/lib/functions/schwefel.rb",
    "chars": 407,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative '../f"
  },
  {
    "path": "model/pso/lib/solver.rb",
    "chars": 3248,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'zero"
  },
  {
    "path": "model/pso/lib/version.rb",
    "chars": 140,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nmodule Pso\n  VERSION ="
  },
  {
    "path": "model/pso/lib/zero_vector.rb",
    "chars": 227,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'matrix'\n\n#\n# "
  },
  {
    "path": "model/pso/pso.rb",
    "chars": 192,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'lib/"
  },
  {
    "path": "model/storage.rb",
    "chars": 821,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'json'\nrequire"
  },
  {
    "path": "nginx.conf.sigil",
    "chars": 6506,
    "preview": "{{ range $port_map := .PROXY_PORT_MAP | split \" \" }}\n{{ $port_map_list := $port_map | split \":\" }}\n{{ $scheme := index $"
  },
  {
    "path": "objects/clients/github.rb",
    "chars": 686,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'octokit'\n\n#\n#"
  },
  {
    "path": "objects/clients/gitlab.rb",
    "chars": 642,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'gitlab'\n\n#\n# "
  },
  {
    "path": "objects/clients/jira.rb",
    "chars": 799,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'rubygems'\nreq"
  },
  {
    "path": "objects/diff.rb",
    "chars": 1722,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\n\n#\n"
  },
  {
    "path": "objects/dynamo.rb",
    "chars": 950,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'yaml'\nrequire"
  },
  {
    "path": "objects/git_repo.rb",
    "chars": 3129,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'base64'\nrequi"
  },
  {
    "path": "objects/invitations/github_invitations.rb",
    "chars": 1279,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Invitations in Git"
  },
  {
    "path": "objects/invitations/github_organization_invitations.rb",
    "chars": 452,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'gith"
  },
  {
    "path": "objects/jobs/job.rb",
    "chars": 656,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'mail'\nrequire"
  },
  {
    "path": "objects/jobs/job_commiterrors.rb",
    "chars": 892,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative '../t"
  },
  {
    "path": "objects/jobs/job_detached.rb",
    "chars": 715,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'fileutils'\n\n#"
  },
  {
    "path": "objects/jobs/job_emailed.rb",
    "chars": 1825,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'mail'\n\n#\n# Jo"
  },
  {
    "path": "objects/jobs/job_recorded.rb",
    "chars": 339,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Job that records a"
  },
  {
    "path": "objects/jobs/job_starred.rb",
    "chars": 345,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Job that stars the"
  },
  {
    "path": "objects/log.rb",
    "chars": 2218,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'base64'\nrequi"
  },
  {
    "path": "objects/maybe_text.rb",
    "chars": 394,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Maybe text\n#\nclass"
  },
  {
    "path": "objects/puzzles.rb",
    "chars": 3162,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'json'\nrequire"
  },
  {
    "path": "objects/storage/cached_storage.rb",
    "chars": 733,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# XML cached in a te"
  },
  {
    "path": "objects/storage/logged_storage.rb",
    "chars": 573,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Storage that is lo"
  },
  {
    "path": "objects/storage/once_storage.rb",
    "chars": 338,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Saves only once, i"
  },
  {
    "path": "objects/storage/s3.rb",
    "chars": 1009,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'aws-sdk-s3'\nr"
  },
  {
    "path": "objects/storage/safe_storage.rb",
    "chars": 572,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\n\n#\n"
  },
  {
    "path": "objects/storage/sync_storage.rb",
    "chars": 348,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Thread-safe storag"
  },
  {
    "path": "objects/storage/upgraded_storage.rb",
    "chars": 591,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Storage that upgra"
  },
  {
    "path": "objects/storage/versioned_storage.rb",
    "chars": 601,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Storage that adds "
  },
  {
    "path": "objects/templates/github_tickets_body.haml",
    "chars": 940,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\nThe puzzle `#{puzzle"
  },
  {
    "path": "objects/templates/gitlab_tickets_body.haml",
    "chars": 940,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\nThe puzzle `#{puzzle"
  },
  {
    "path": "objects/templates/jira_tickets_body.haml",
    "chars": 940,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\nThe puzzle `#{puzzle"
  },
  {
    "path": "objects/tickets/commit_tickets.rb",
    "chars": 1685,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Tickets that post "
  },
  {
    "path": "objects/tickets/emailed_tickets.rb",
    "chars": 1711,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Tickets that email"
  },
  {
    "path": "objects/tickets/logged_tickets.rb",
    "chars": 1927,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'cgi'\nrequire_"
  },
  {
    "path": "objects/tickets/milestone_tickets.rb",
    "chars": 1630,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Tickets that inher"
  },
  {
    "path": "objects/tickets/sentry_tickets.rb",
    "chars": 1442,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'mail'\nrequire"
  },
  {
    "path": "objects/tickets/tagged_tickets.rb",
    "chars": 1789,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Tagged tickets.\n#\n"
  },
  {
    "path": "objects/tickets/tickets.rb",
    "chars": 2821,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'haml'\nrequire"
  },
  {
    "path": "objects/truncated.rb",
    "chars": 469,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Truncated text.\n#\n"
  },
  {
    "path": "objects/user_error.rb",
    "chars": 151,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# User Error\n#\nclass"
  },
  {
    "path": "objects/vcs/github.rb",
    "chars": 3487,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'octokit'\nrequ"
  },
  {
    "path": "objects/vcs/gitlab.rb",
    "chars": 4243,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'gitlab'\nrequi"
  },
  {
    "path": "objects/vcs/jira.rb",
    "chars": 1965,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'jira-ruby'\nre"
  },
  {
    "path": "renovate.json",
    "chars": 107,
    "preview": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"extends\": [\n    \"config:base\"\n  ]\n}\n"
  },
  {
    "path": "test/fake_github.rb",
    "chars": 3204,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nclass FakeGithub\n  att"
  },
  {
    "path": "test/fake_gitlab.rb",
    "chars": 1973,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nclass FakeGitlab\n  att"
  },
  {
    "path": "test/fake_log.rb",
    "chars": 340,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nclass FakeLog\n  attr_r"
  },
  {
    "path": "test/fake_repo.rb",
    "chars": 461,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/fake_storage.rb",
    "chars": 440,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/fake_tickets.rb",
    "chars": 413,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nclass FakeTickets\n  at"
  },
  {
    "path": "test/test_0pdd.rb",
    "chars": 7982,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'rack/test'\nre"
  },
  {
    "path": "test/test__helper.rb",
    "chars": 911,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nENV['RACK_ENV'] = 'tes"
  },
  {
    "path": "test/test_cached_storage.rb",
    "chars": 679,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test"
  },
  {
    "path": "test/test_commit_tickets.rb",
    "chars": 1304,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'yaml'\nrequire"
  },
  {
    "path": "test/test_credentials.rb",
    "chars": 2834,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'mail'\nrequire"
  },
  {
    "path": "test/test_diff.rb",
    "chars": 6196,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_diff_complicated.rb",
    "chars": 1783,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_git_repo.rb",
    "chars": 6160,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'tmpdir'\nrequi"
  },
  {
    "path": "test/test_github.rb",
    "chars": 541,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test"
  },
  {
    "path": "test/test_github_invitations.rb",
    "chars": 1527,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test"
  },
  {
    "path": "test/test_github_tickets.rb",
    "chars": 5130,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_gitlab.rb",
    "chars": 530,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test"
  },
  {
    "path": "test/test_job.rb",
    "chars": 796,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_job_commiterrors.rb",
    "chars": 891,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test"
  },
  {
    "path": "test/test_job_detached.rb",
    "chars": 585,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test"
  },
  {
    "path": "test/test_job_emailed.rb",
    "chars": 902,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'veil'\nrequire"
  },
  {
    "path": "test/test_log.rb",
    "chars": 569,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_logged_storage.rb",
    "chars": 749,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test"
  },
  {
    "path": "test/test_logged_tickets.rb",
    "chars": 1404,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_maybe_text.rb",
    "chars": 797,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test"
  },
  {
    "path": "test/test_milestone_tickets.rb",
    "chars": 3575,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_once_storage.rb",
    "chars": 979,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test"
  },
  {
    "path": "test/test_puzzles.rb",
    "chars": 2861,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_safe_storage.rb",
    "chars": 1554,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_sentry_tickets.rb",
    "chars": 823,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'mail'\nrequire"
  },
  {
    "path": "test/test_svg.rb",
    "chars": 2218,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_truncated.rb",
    "chars": 1025,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test"
  },
  {
    "path": "test/test_upgraded_storage.rb",
    "chars": 1662,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test/test_versioned_storage.rb",
    "chars": 698,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nreq"
  },
  {
    "path": "test-assets/puzzles/closes-one-puzzle.xml",
    "chars": 1132,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "test-assets/puzzles/ignores-unknown-issues.xml",
    "chars": 874,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "test-assets/puzzles/notify-unknown-open-issues.xml",
    "chars": 425,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "test-assets/puzzles/simple.xml",
    "chars": 2024,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "test-assets/puzzles/submits-old-puzzles.xml",
    "chars": 2361,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "test-assets/puzzles/submits-ranked-puzzles.xml",
    "chars": 1706,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "test-assets/puzzles/submits-three-tickets.xml",
    "chars": 1680,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-L"
  },
  {
    "path": "version.rb",
    "chars": 123,
    "preview": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nVERSION = 'BUILD'.free"
  },
  {
    "path": "views/_footer.haml",
    "chars": 121,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%p\n  ='--'\n%p\n  =ver"
  },
  {
    "path": "views/_header.haml",
    "chars": 375,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%p\n  %a{href:'/'}\n  "
  },
  {
    "path": "views/error.haml",
    "chars": 429,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%p\n  %a{href:'/'}\n  "
  },
  {
    "path": "views/error_400.haml",
    "chars": 142,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%p\n  Request error\n%"
  },
  {
    "path": "views/index.haml",
    "chars": 1553,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%div.center\n  %p\n   "
  },
  {
    "path": "views/item.haml",
    "chars": 830,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n= Haml::Engine.new(F"
  },
  {
    "path": "views/layout.haml",
    "chars": 1007,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n!!! 5\n%html\n  %head\n"
  },
  {
    "path": "views/log.haml",
    "chars": 1324,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n= Haml::Engine.new(F"
  },
  {
    "path": "views/not_found.haml",
    "chars": 173,
    "preview": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%p\n  This page is no"
  }
]

About this extraction

This page contains the full source code of the yegor256/0pdd GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 152 files (211.3 KB), approximately 66.6k tokens, and a symbol index with 476 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!