[
  {
    "path": ".0pdd.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\nerrors:\n  - yegor256@gmail.com\n# alerts:\n#   github:\n#     - yegor256\n\ntags:\n  - pdd\n  - bug\n"
  },
  {
    "path": ".gitattributes",
    "content": "# 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 it, please\n# submit a separate issue about it and we'll discuss.\n\n* text=auto eol=lf\n*.java ident\n*.xml ident\n*.png binary\n"
  },
  {
    "path": ".github/workflows/actionlint.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: actionlint\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  actionlint:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - name: Download actionlint\n        id: get_actionlint\n        run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)\n        shell: bash\n      - name: Check workflow files\n        run: ${{ steps.get_actionlint.outputs.executable }} -color\n        shell: bash\n"
  },
  {
    "path": ".github/workflows/bashate.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: bashate\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  bashate:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: actions/setup-python@v6\n        with:\n          python-version: 3.14\n      - run: pip install bashate\n      - run: |\n          readarray -t files < <(find . -name '*.sh')\n          bashate -i E006,E003 \"${files[@]}\"\n"
  },
  {
    "path": ".github/workflows/codecov.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: codecov\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  codecov:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - run: sudo apt-get install --yes libmagic-dev\n      - uses: ruby/setup-ruby@v1\n        with:\n          ruby-version: 3.4.9\n          bundler-cache: true\n      - run: bundle config set --global path \"$(pwd)/vendor/bundle\"\n      - run: bundle install --no-color\n      - run: bundle exec rake\n      - uses: codecov/codecov-action@v6\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          fail_ci_if_error: true\n"
  },
  {
    "path": ".github/workflows/copyrights.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: copyrights\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  copyrights:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: yegor256/copyrights-action@0.0.12\n"
  },
  {
    "path": ".github/workflows/markdown-lint.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: markdown-lint\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  markdown-lint:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: DavidAnson/markdownlint-cli2-action@v23.2.0\n"
  },
  {
    "path": ".github/workflows/pdd.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: pdd\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  pdd:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: volodya-lombrozo/pdd-action@master\n"
  },
  {
    "path": ".github/workflows/plantuml.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: plantuml\n'on':\n  push:\n    paths:\n      - '**.puml'\n    branches:\n      - master\npermissions:\n  contents: write\njobs:\n  plantuml:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - name: Checkout Source\n        uses: actions/checkout@v6\n      - name: Generate SVG Diagrams\n        uses: holowinski/plantuml-github-action@main\n        with:\n          args: -v -tsvg doc/*.puml\n      - name: Commit changes\n        uses: EndBug/add-and-commit@v10\n        with:\n          author_name: ${{ github.actor }}\n          author_email: ${{ github.event.pusher.email }}\n          message: 'Diagram generated'\n          add: 'doc/*'\n"
  },
  {
    "path": ".github/workflows/rake.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: rake\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  test:\n    strategy:\n      matrix:\n        os: [ubuntu-24.04]\n        ruby: [3.3]\n    runs-on: ${{ matrix.os }}\n    steps:\n      - uses: actions/checkout@v6\n      - run: sudo apt-get install --yes libmagic-dev\n      - uses: ruby/setup-ruby@v1\n        with:\n          ruby-version: ${{ matrix.ruby }}\n          bundler-cache: true\n      - run: bundle config set --global path \"$(pwd)/vendor/bundle\"\n      - run: bundle install --no-color\n      - run: bundle exec rake\n"
  },
  {
    "path": ".github/workflows/reuse.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: reuse\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  reuse:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: fsfe/reuse-action@v6\n"
  },
  {
    "path": ".github/workflows/shellcheck.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: shellcheck\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  shellcheck:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: ludeeus/action-shellcheck@master\n"
  },
  {
    "path": ".github/workflows/typos.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: typos\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  typos:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: crate-ci/typos@v1.46.1\n"
  },
  {
    "path": ".github/workflows/xcop.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: xcop\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  xcop:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: g4s8/xcop-action@master\n"
  },
  {
    "path": ".github/workflows/yamllint.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\nname: yamllint\n'on':\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\njobs:\n  yamllint:\n    timeout-minutes: 15\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: ibiqlik/action-yamllint@v3\n"
  },
  {
    "path": ".gitignore",
    "content": "*.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",
    "content": "--source=.\n--verbose\n--exclude README.md\n--exclude coverage/**/*\n--exclude assets/**/*\n--exclude model/data/**/*\n--rule min-words:10\n--rule min-estimate:15\n--rule max-estimate:90\n"
  },
  {
    "path": ".rubocop.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\nAllCops:\n  Exclude:\n    - 'bin/**/*'\n    - 'assets/**/*'\n    - 'vendor/**/*'\n  DisplayCopNames: true\n  TargetRubyVersion: 2.6.0\n  NewCops: enable\n  SuggestExtensions: false\nplugins:\n  - rubocop-rake\n  - rubocop-minitest\n  - rubocop-performance\nLayout/MultilineOperationIndentation:\n  Enabled: false\nLayout/EmptyLineAfterGuardClause:\n  Enabled: false\nNaming/MethodParameterName:\n  MinNameLength: 1\nStyle/CommandLiteral:\n  Enabled: false\nStyle/FrozenStringLiteralComment:\n  Enabled: false\nLayout/IndentationWidth:\n  Enabled: false\nMinitest/EmptyLineBeforeAssertionMethods:\n  Enabled: false\nLayout/ElseAlignment:\n  Enabled: false\nNaming/PredicateMethod:\n  Enabled: false\nLayout/EndAlignment:\n  Enabled: false\nLint/RescueException:\n  Enabled: false\nMetrics/MethodLength:\n  Max: 50\nMetrics/ClassLength:\n  Max: 200\n  Exclude:\n    - \"test/test_*.rb\"\nMetrics/AbcSize:\n  Max: 60\nMetrics/BlockLength:\n  Max: 100\nLayout/MultilineMethodCallIndentation:\n  Enabled: false\nMetrics/CyclomaticComplexity:\n  Max: 11\nMetrics/PerceivedComplexity:\n  Max: 11\nLayout/LineLength:\n  Max: 120\nStyle/OpenStructUse:\n  Enabled: false\nStyle/ComparableClamp:\n  Enabled: false\n"
  },
  {
    "path": ".rultor.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\n# yamllint disable rule:line-length\ndocker:\n  image: yegor256/rultor-image:1.24.0\nassets:\n  config.yml: yegor256/home#assets/0pdd/config.yml\n  id_rsa: yegor256/home#assets/heroku-key\n  id_rsa.pub: yegor256/home#assets/heroku-key.pub\ninstall: |-\n  git config --global user.email \"server@0pdd.com\"\n  git config --global user.name \"0pdd.com\"\n  sudo gem install pdd\n  pdd -f /dev/null\n  bundle install --no-color\nrelease:\n  pre: false\n  sensitive:\n    - config.yml\n  script: |-\n    [[ \"${tag}\" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]] || exit -1\n    bundle exec rake\n    git remote add dokku dokku@dokku.0pdd.com:zeropdd\n    rm -rf ~/.ssh\n    mkdir ~/.ssh\n    mv ../id_rsa ../id_rsa.pub ~/.ssh\n    chmod -R 600 ~/.ssh/*\n    echo -e \"Host *\\n  StrictHostKeyChecking no\\n  UserKnownHostsFile=/dev/null\" > ~/.ssh/config\n    git fetch\n    sed -i \"s/BUILD/${tag}/g\" ./version.rb\n    git add ./version.rb\n    git commit --no-verify -m 'build number set'\n    cp ../config.yml config.yml\n    git add config.yml\n    bundle exec ruby test/test_credentials.rb\n    git commit --no-verify -m 'config.yml'\n    git push -f dokku $(git symbolic-ref --short HEAD):master\n    git reset HEAD~1\n    rm -rf config.yml\n    curl -f --connect-timeout 15 -k --retry 5 --retry-delay 30 https://www.0pdd.com > /dev/null\nmerge:\n  script: |-\n    bundle exec rake\n"
  },
  {
    "path": "0pdd.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n$stdout.sync = true\n\nrequire 'glogin'\nrequire 'haml'\nrequire 'json'\nrequire 'mail'\nrequire 'net/http'\nrequire 'octokit'\nrequire 'ostruct'\nrequire 'qbash'\nrequire 'rack'\nrequire 'sentry-ruby'\nrequire 'sass'\nrequire 'sinatra'\nrequire 'sinatra/cookies'\nrequire 'tmpdir'\nrequire 'uri'\n\nrequire_relative 'version'\nrequire_relative 'objects/log'\nrequire_relative 'objects/dynamo'\nrequire_relative 'objects/git_repo'\nrequire_relative 'objects/user_error'\nrequire_relative 'objects/vcs/github'\nrequire_relative 'objects/vcs/gitlab'\nrequire_relative 'objects/clients/github'\nrequire_relative 'objects/clients/gitlab'\nrequire_relative 'objects/jobs/job'\nrequire_relative 'objects/jobs/job_detached'\nrequire_relative 'objects/jobs/job_emailed'\nrequire_relative 'objects/jobs/job_recorded'\nrequire_relative 'objects/jobs/job_starred'\nrequire_relative 'objects/jobs/job_commiterrors'\nrequire_relative 'objects/tickets/tickets'\nrequire_relative 'objects/tickets/tagged_tickets'\nrequire_relative 'objects/tickets/emailed_tickets'\nrequire_relative 'objects/tickets/logged_tickets'\nrequire_relative 'objects/tickets/commit_tickets'\nrequire_relative 'objects/tickets/sentry_tickets'\nrequire_relative 'objects/tickets/milestone_tickets'\nrequire_relative 'objects/storage/s3'\nrequire_relative 'objects/storage/safe_storage'\nrequire_relative 'objects/storage/sync_storage'\nrequire_relative 'objects/storage/logged_storage'\nrequire_relative 'objects/storage/versioned_storage'\nrequire_relative 'objects/storage/upgraded_storage'\nrequire_relative 'objects/storage/cached_storage'\nrequire_relative 'objects/storage/once_storage'\nrequire_relative 'objects/invitations/github_invitations'\n\nrequire_relative 'test/fake_storage'\n\nconfigure do\n  Haml::Options.defaults[:format] = :xhtml\n  config = if ENV['RACK_ENV'] == 'test'\n    {\n      'testing' => true,\n      'github' => {\n        'token' => '--the-token--',\n        'client_id' => '?',\n        'client_secret' => '?'\n      },\n      'gitlab' => {\n        'token' => '--the-token--',\n        'client_id' => '?',\n        'client_secret' => '?'\n      },\n      'jira' => {\n        'token' => '--the-token--',\n        'client_id' => '?',\n        'client_secret' => '?'\n      },\n      'sentry' => '',\n      's3' => {\n        'region' => '?',\n        'bucket' => '?',\n        'key' => '?',\n        'secret' => '?'\n      },\n      'id_rsa' => ''\n    }\n  else\n    config = YAML.safe_load(File.open(File.join(File.dirname(__FILE__), 'config.yml')))\n    raise 'Missing configuration file config.yml' if config.nil?\n    config\n  end\n  if ENV['RACK_ENV'] != 'test'\n    Sentry.init do |c|\n      c.dsn = config['sentry']\n      c.release = VERSION\n    end\n  end\n  set :config, config\n  if config['smtp']\n    Mail.defaults do\n      delivery_method(\n        :smtp,\n        address: config['smtp']['host'],\n        port: config['smtp']['port'],\n        user_name: config['smtp']['user'],\n        password: config['smtp']['password'],\n        domain: '0pdd.com',\n        enable_starttls_auto: true\n      )\n    end\n  end\n  set :server_settings, timeout: 25\n  set :github, Github.new(config).client\n  set :gitlab, GitlabClient.new(config).client\n  set :dynamo, Dynamo.new(config).aws\n  set :glogin, GLogin::Auth.new(\n    config['github']['client_id'],\n    config['github']['client_secret'],\n    'https://www.0pdd.com/github-callback'\n  )\n  set :ruby_version, qbash('ruby -e \"print RUBY_VERSION\"')\n  set :git_version, qbash('git --version | cut -d\" \" -f 3')\n  set :temp_dir, Dir.mktmpdir('0pdd')\n  if ENV['RACK_ENV'] != 'test'\n    Thread.new do\n      loop do\n        sleep(10)\n        Net::HTTP.get_response(URI('https://www.0pdd.com/ping-github'))\n      rescue Exception\n        # If we reach this point, we must not even try to\n        # do anything. Here we must quietly ignore everything\n        # and let the daemon go to the next cycle.\n      end\n    end\n  end\nend\nuse Rack::Deflater\n# @todo #572:1h rewind is removed from rack 3.0, so it is moved to\n#  rewindableInput for now, but it is better to check another solutions\nuse Rack::RewindableInput::Middleware\n\nbefore '/*' do\n  @locals = {\n    ver: VERSION,\n    login_link: settings.glogin.login_uri\n  }\n  if cookies[:glogin]\n    begin\n      @locals[:user] = GLogin::Cookie::Closed.new(\n        cookies[:glogin],\n        settings.config['github']['encryption_secret']\n      ).to_user\n    rescue OpenSSL::Cipher::CipherError\n      @locals.delete(:user)\n    end\n  end\nend\n\nget '/github-callback' do\n  code = params[:code]\n  redirect('/') if code.nil?\n  cookies[:glogin] = GLogin::Cookie::Open.new(\n    settings.glogin.user(code),\n    settings.config['github']['encryption_secret']\n  ).to_s\n  redirect to('/')\nend\n\nget '/logout' do\n  cookies.delete(:glogin)\n  redirect to('/')\nend\n\nget '/' do\n  projects = qbash(\n    \"(sort /tmp/0pdd-done.txt 2>/dev/null || echo '')\\\n    | uniq\"\n  ).split(\"\\n\").reject(&:empty?)\n  haml :index, layout: :layout, locals: merged(\n    title: '0pdd',\n    ruby_version: settings.ruby_version,\n    git_version: settings.git_version,\n    remaining: settings.github.rate_limit.remaining,\n    tail: projects.last(10).reverse\n  )\nend\n\nget '/robots.txt' do\n  'User-agent: *\nDisallow: /snapshot'\nend\n\nget '/version' do\n  VERSION\nend\n\nget '/invitation' do\n  repo = repo_name(params[:repo])\n  ghi = GithubInvitations.new(settings.github)\n  invitations = ghi.accept_single_invitation(repo)\n  return invitations.join('\\n') unless invitations.empty?\n  \"Could not find invitation for @#{repo}. It is either invitation already\n   accepted OR 0pdd is not added as a collaborator\"\nend\n\nget '/p' do\n  vcs = vcs_name(params[:vcs])\n  name = repo_name(params[:name])\n  xml = storage(name, vcs).load\n  Nokogiri::XSLT(File.read('assets/xsl/puzzles.xsl')).transform(\n    xml,\n    [\n      'version', \"'#{VERSION}'\",\n      'project', \"'#{name}'\",\n      'length', xml.to_s.length.to_s\n    ]\n  ).to_s\nend\n\nget '/xml' do\n  content_type 'text/xml'\n  vcs = vcs_name(params[:vcs])\n  storage(repo_name(params[:name]), vcs).load.to_s\nend\n\nget '/log' do\n  vcs = vcs_name(params[:vcs])\n  repo = repo_name(params[:name])\n  haml :log, layout: :layout, locals: merged(\n    title: repo,\n    repo: repo,\n    log: Log.new(settings.dynamo, repo, vcs),\n    since: params[:since] ? params[:since].to_i : Time.now.to_i + 1\n  )\nend\n\nget '/snapshot' do\n  content_type 'text/xml'\n  master = params[:branch]\n  vcs = vcs_name(params[:vcs])\n  name = repo_name(params[:name])\n  uri = \"git@github.com:#{name}.git\"\n  uri = \"git@gitlab.com:#{name}.git\" if vcs == 'gitlab'\n  begin\n    repo = GitRepo.new(\n      uri: uri,\n      name: name,\n      id_rsa: settings.config['id_rsa'],\n      dir: settings.temp_dir,\n      master: master || 'master'\n    )\n    repo.push\n    xml = repo.xml\n    xml.xpath('//processing-instruction(\"xml-stylesheet\")').remove\n    xml.to_s\n  rescue StandardError => e\n    error 400, \"Could not get snapshot for #{name}: #{e.message}\"\n  end\nend\n\nget '/log-item' do\n  vcs = vcs_name(params[:vcs])\n  repo = repo_name(params[:repo])\n  tag = params[:tag]\n  error 404 if tag.nil?\n  log = Log.new(settings.dynamo, repo, vcs)\n  error 404 unless log.exists(tag)\n  haml :item, layout: :layout, locals: merged(\n    title: tag,\n    repo: repo,\n    item: log.get(tag)\n  )\nend\n\nget '/log-delete' do\n  redirect '/' if @locals[:user].nil? || @locals[:user][:login] != 'yegor256'\n  repo = repo_name(params[:name])\n  vcs = vcs_name(params[:vcs])\n  Log.new(settings.dynamo, repo, vcs).delete(params[:time].to_i, params[:tag])\n  redirect \"/log?name=#{repo}\"\nend\n\nget '/svg' do\n  response.headers['Cache-Control'] = 'no-cache, private'\n  content_type 'image/svg+xml'\n  name = repo_name(params[:name])\n  vcs = vcs_name(params[:vcs])\n  Nokogiri::XSLT(File.read('assets/xsl/svg.xsl')).transform(\n    storage(name, vcs).load, ['project', \"'#{name}'\"]\n  ).to_s\nend\n\nget '/ping-github' do\n  content_type 'text/plain'\n  gh = settings.github\n  return if gh.rate_limit.remaining < 1000\n  invitations = GithubInvitations.new(gh)\n  invitations.accept\n  invitations.accept_orgs\n  msgs = gh.notifications.map do |n|\n    reason = n['reason']\n    repo = n['repository']['full_name']\n    puts \"GitHub notification in #{repo}: #{reason} #{n['updated_at']} #{n['subject']['type']}\"\n    if reason == 'mention'\n      issue = n['subject']['url'].gsub(%r{^.+/issues/}, '').to_i\n      comment = n['subject']['latest_comment_url'].gsub(%r{^.+/comments/}, '').to_i\n      begin\n        json = gh.issue_comment(repo, comment)\n        body = json['body']\n        if body.start_with?(\"@#{gh.login}\") && json['user']['login'] != gh.login\n          gh.add_comment(\n            repo,\n            issue,\n            \"> #{body.gsub(/\\s+/, ' ').gsub(/^(.{100,}?).*$/m, '\\1...')}\\n\\n\" \\\n            \"I see you're talking to me, but I can't reply since I'm not a chat bot.\"\n          )\n          puts \"Replied to #{repo}##{issue}\"\n        end\n      rescue Octokit::NotFound => e\n        puts \"Failed: #{e.message}\"\n        next\n      end\n    end\n    \"#{repo}: #{reason}\"\n  end\n  gh.mark_notifications_as_read(last_read_at: Time.now)\n  \"#{msgs.join(\"\\n\")}\\n\"\nend\n\nget '/hook/github' do\n  'This URL expects POST requests from GitHub\n  WebHook: https://developer.github.com/webhooks/'\nend\n\npost '/hook/github' do\n  is_from_github = request.env['HTTP_USER_AGENT']&.start_with?('GitHub-Hookshot')\n  is_push_event = request.env['HTTP_X_GITHUB_EVENT'] == 'push'\n  unless is_from_github && is_push_event\n    return [\n      400,\n      'Please, only register push events from GitHub webhook'\n    ]\n  end\n  request.env['rack.input'].rewind if request.env['rack.input'].respond_to?(:rewind)\n  request.body.rewind unless request.env['rack.input'].respond_to?(:rewind)\n  json = JSON.parse(\n    case request.content_type\n    when 'application/x-www-form-urlencoded'\n      payload = params[:payload]\n      # see https://docs.github.com/en/webhooks/using-webhooks/creating-webhooks\n      if payload.nil?\n        return [\n          400,\n          'URL-encoded content is expected in the \"payload\" query parameter, but it is not provided'\n        ]\n      end\n      payload\n    when 'application/json'\n      request.body.read\n    else\n      raise \"Invalid content-type: \\\"#{request.content_type}\\\"\"\n    end\n  )\n  github = GithubRepo.new(settings.github, json, settings.config)\n  return [400, \"No access to #{github.repo.name}\"] unless github.exists?\n  unless ENV['RACK_ENV'] == 'test'\n    process_request(github) if github.repo.change_in_master?\n    puts \"GitHub hook from #{github.repo.name} to branch #{github.repo.target}\"\n  end\n  ignore = ''\n  ignore = 'Push is not to master branch, nothing is done. ' unless github.repo.change_in_master?\n  \"#{ignore}Thanks #{github.repo.name}\"\nend\n\nget '/hook/gitlab' do\n  'This URL expects POST requests from Gitlab\n  WebHook: https://docs.gitlab.com/ee/user/project/integrations/webhooks.html'\nend\n\npost '/hook/gitlab' do\n  is_from_gitlab = request.env['HTTP_USER_AGENT'].start_with?('GitLab')\n  is_push_event = request.env['HTTP_X_GITLAB_EVENT'] == 'Push Hook'\n  unless is_from_gitlab && is_push_event\n    return [\n      400,\n      'Please, only register push events from Gitlab webhook'\n    ]\n  end\n  request.env['rack.input'].rewind if request.env['rack.input'].respond_to?(:rewind)\n  request.body.rewind unless request.env['rack.input'].respond_to?(:rewind)\n  json = JSON.parse(\n    case request.content_type\n    when 'application/x-www-form-urlencoded'\n      params[:payload]\n    when 'application/json'\n      request.body.read\n    else\n      raise \"Invalid content-type: \\\"#{request.content_type}\\\"\"\n    end\n  )\n  gitlab = GitlabRepo.new(settings.gitlab, json, settings.config)\n  return [400, \"No access to #{gitlab.repo.name}\"] unless gitlab.exists?\n  unless ENV['RACK_ENV'] == 'test'\n    process_request(gitlab) if gitlab.repo.change_in_master?\n    puts \"Gitlab hook from #{gitlab.repo.name} to branch #{gitlab.repo.target}\"\n  end\n  ignore = ''\n  ignore = 'Push is not to master branch, nothing is done. ' unless gitlab.repo.change_in_master?\n  \"#{ignore}Thanks #{gitlab.repo.name}\"\nend\n\nget '/css/*.css' do\n  content_type 'text/css', charset: 'utf-8'\n  file = params[:splat].first\n  template = File.join(File.absolute_path('./assets/sass/'), \"#{file}.sass\")\n  Sass::Engine.new(File.read(template)).render\nend\n\nget '/puzzles.xsd' do\n  content_type 'application/xml', charset: 'utf-8'\n  File.read('assets/xsd/puzzles.xsd')\nend\n\nnot_found do\n  status 404\n  content_type 'text/html', charset: 'utf-8'\n  haml :not_found, layout: :layout, locals: merged(\n    title: 'Page not found'\n  )\nend\n\nerror do\n  status 503\n  e = env['sinatra.error']\n  Sentry.capture_exception(e) unless e.is_a?(UserError)\n  haml(\n    :error,\n    layout: :layout,\n    locals: merged(\n      title: 'error',\n      error: \"#{e.message}\\n\\t#{e.backtrace.join(\"\\n\\t\")}\"\n    )\n  )\nend\n\ndef repo_name(name)\n  error 404 if name.nil?\n  error 404 unless %r{^[a-zA-Z0-9\\-_]+/[a-zA-Z0-9\\-_.]+$}.match?(name)\n  name.strip\nend\n\ndef vcs_name(name)\n  return 'github' if name.nil?\n  name.strip.downcase\nend\n\ndef merged(hash)\n  out = @locals.merge(hash)\n  out[:local_assigns] = out\n  out\nend\n\ndef storage(repo, vcs)\n  file_name = vcs == 'github' ? repo : \"#{vcs}-#{repo}\"\n  SyncStorage.new(\n    UpgradedStorage.new(\n      SafeStorage.new(\n        OnceStorage.new(\n          CachedStorage.new(\n            VersionedStorage.new(\n              if ENV['RACK_ENV'] == 'test'\n                FakeStorage.new\n              else\n                LoggedStorage.new(\n                  S3.new(\n                    \"#{file_name}.xml\",\n                    settings.config['s3']['bucket'],\n                    settings.config['s3']['region'],\n                    settings.config['s3']['key'],\n                    settings.config['s3']['secret']\n                  ),\n                  Log.new(settings.dynamo, repo, vcs)\n                )\n              end,\n              VERSION\n            ),\n            File.join('/tmp/0pdd-xml-cache', file_name)\n          )\n        )\n      ),\n      VERSION\n    )\n  )\nend\n\ndef process_request(vcs)\n  JobDetached.new(\n    vcs,\n    JobCommitErrors.new(\n      vcs,\n      JobEmailed.new(\n        vcs,\n        JobRecorded.new(\n          vcs,\n          JobStarred.new(\n            vcs,\n            Job.new(\n              vcs,\n              storage(vcs.repo.name, vcs.name),\n              SentryTickets.new(\n                EmailedTickets.new(\n                  vcs,\n                  CommitTickets.new(\n                    vcs,\n                    TaggedTickets.new(\n                      vcs,\n                      LoggedTickets.new(\n                        vcs,\n                        Log.new(settings.dynamo, vcs.repo.name, vcs.name),\n                        MilestoneTickets.new(\n                          vcs,\n                          Tickets.new(vcs)\n                        )\n                      )\n                    )\n                  )\n                )\n              )\n            )\n          )\n        )\n      )\n    )\n  ).proceed\nend\n"
  },
  {
    "path": "Aptfile",
    "content": "git\n"
  },
  {
    "path": "Gemfile",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nsource 'https://rubygems.org'\n\ngem 'atlassian-jwt', '~>0.2.1'\ngem 'aws-sdk-dynamodb', '~>1.111'\ngem 'aws-sdk-s3', '~>1.176'\ngem 'crack', '~>1.0'\ngem 'faraday', '~>2.14'\ngem 'gitlab', '~>6.0'\ngem 'glogin', '~>0.16'\ngem 'haml', '~>5.2'\ngem 'jira-ruby', '~>3.0'\ngem 'mail', '~>2.8'\ngem 'matrix', '~>0.4'\ngem 'minitest', '~>6.0', require: false\ngem 'minitest-reporters', '~>1.7', require: false\ngem 'net-smtp', '~>0.5'\ngem 'nokogiri', '~>1.18'\ngem 'octokit', '~>10.0'\ngem 'ostruct', '~>0.6'\ngem 'pdd', '~>0.24'\ngem 'qbash', '~>0.4'\ngem 'rack', '~>3.1'\ngem 'rack-test', '~>2.2'\ngem 'rackup', '~>2.2'\ngem 'rake', '~>13.2', require: false\ngem 'rubocop', '~>1.69', require: false\ngem 'rubocop-minitest', '~>0.38', require: false\ngem 'rubocop-performance', '~>1.26', require: false\ngem 'rubocop-rake', '~>0.7', require: false\ngem 'sass', '~>3.7'\ngem 'sentry-ruby', '~>6.2'\ngem 'simplecov', '~>0.22'\ngem 'simplecov-cobertura', '~>3.1'\ngem 'sinatra', '~>4.0'\ngem 'sinatra-contrib', '~>4.0'\ngem 'sprockets', '~>4.2'\ngem 'veils', '~>0.4'\ngem 'webrick', '~>1.9'\ngem 'xcop', '~>0.7'\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "(The MIT License)\n\nCopyright (c) 2016-2026 Yegor Bugayenko\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the 'Software'), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "LICENSES/MIT.txt",
    "content": "(The MIT License)\n\nCopyright (c) 2016-2026 Yegor Bugayenko\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the 'Software'), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Procfile",
    "content": "web: bundle exec rackup config.ru -p $PORT\ncron: curl -s https://www.0pdd.com/ping-github\n"
  },
  {
    "path": "README.md",
    "content": "# Puzzle Driven Development (PDD) GitHub Chatbot\n\n[![EO principles respected here](https://www.elegantobjects.org/badge.svg)](https://www.elegantobjects.org)\n[![DevOps By Rultor.com](https://www.rultor.com/b/yegor256/0pdd)](https://www.rultor.com/p/yegor256/0pdd)\n[![We recommend RubyMine](https://www.elegantobjects.org/rubymine.svg)](https://www.jetbrains.com/ruby/)\n\n[![rake](https://github.com/yegor256/0pdd/actions/workflows/rake.yml/badge.svg)](https://github.com/yegor256/0pdd/actions/workflows/rake.yml)\n[![Availability at SixNines](https://www.sixnines.io/b/574a)](https://www.sixnines.io/h/574a)\n[![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)\n[![PDD status](https://www.0pdd.com/svg?name=yegor256/0pdd)](https://www.0pdd.com/p?name=yegor256/0pdd)\n[![Maintainability](https://api.codeclimate.com/v1/badges/7462387124cf5f9b8ef8/maintainability)](https://codeclimate.com/github/yegor256/0pdd/maintainability)\n[![Test Coverage](https://img.shields.io/codecov/c/github/yegor256/0pdd.svg)](https://codecov.io/github/yegor256/0pdd?branch=master)\n[![Hits-of-Code](https://hitsofcode.com/github/yegor256/0pdd)](https://hitsofcode.com/view/github/yegor256/0pdd)\n[![Codacy Badge](https://app.codacy.com/project/badge/Grade/d23061346143451db3abedca5ad9cbf2)](https://www.codacy.com/gh/yegor256/0pdd/dashboard)\n\nRead this blog post first:\n[PDD in Action](https://www.yegor256.com/2017/04/05/pdd-in-action.html).\nTL;DR:\n\n1. Your boss tells you to fix issue `#42`\n1. You do it, but not completely (you have no time, you are lazy, etc)\n1. You put `TODO #42:30min bla-bla-bla` into the codebase (in a pull request)\n1. CI checks that you didn't break the format of the `TODO`\n(reuse our [`pdd.yml`][pdd.yml])\n1. You merge the pull request\n1. The bot picks up the `TODO` and creates issue `#43` (new one)\n1. The boss asks your friend to fix `#43`\n1. The friend fixes it, and merges\n1. The `TODO` is gone from the codebase\n1. The bot closes the issue `#43`\n\n[0pdd.com](https://www.0pdd.com) is a hosted service that\nfinds new \"puzzles\" in your repository and posts them as GitHub\nissues. To start using it just create a\n[Webhook](https://developer.github.com/webhooks/creating/) in your repository\njust for `push` events with `https://www.0pdd.com/hook/github` payload URL and\n`application/json` content type.\n\nThen, add [@0pdd](https://github.com/0pdd) GitHub user as a\n[collaborator] to your repository, if it's private\n(you don't need this for a public repository).\n\nIf your invitation is not accepted by [@0pdd](https://github.com/0pdd)\nwithin 30 minutes, visit this address:\n`https://0pdd.com/invitation?repo={REPO_FULL_NAME}`, where `REPO_FULL_NAME`\nis the full name of your repo, e.g., `yegor256/0pdd`.\n\nThen, add a `@todo` [puzzle](https://www.yegor256.com/2009/03/04/pdd.html)\nto the source code (format it [right](https://github.com/teamed/pdd)).\n\nThen, `git push` something to the master branch and see what happens.\nYou should see a new\nissue created in your repository by [@0pdd](https://github.com/0pdd).\n\nYou can find the dependency tree of all puzzles in your repository\nhere: `https://www.0pdd.com/p?name=yegor256/0pdd` (just replace the name\nof the repo in the URL).\n\nDon't forget to add that cute little badge to your `README.md`, just\nlike we did here in this repo (see above). The Markdown you need\nwill look like this (replace `yegor256/0pdd` with GitHub coordinates\nof your own repository):\n\n```markdown\n[![PDD status](https://www.0pdd.com/svg?name=yegor256/0pdd)](https://www.0pdd.com/p?name=yegor256/0pdd)\n```\n\n## How to configure?\n\nThe only way to configure 0pdd is to add `.0pdd.yml` file to the\nroot directory of your `master` branch (see\n[this one](https://github.com/yegor256/0pdd/blob/master/.0pdd.yml)\nas a live example).\nIt has to be a [YAML](https://en.wikipedia.org/wiki/YAML)\nfile with the following\noptional parameters inside:\n\n```yaml\nthreshold: 10\nmodel: true\nerrors:\n  - yegor256@gmail.com\nalerts:\n  suppress:\n    - on-found-puzzle\n    - on-lost-puzzle\n    - on-scope\n  github:\n    - yegor256\nformat:\n  - short-title\n  - title-length=100\ntags:\n  - pdd\n  - bug\n```\n\nThe element `threshold` allows you to limit the number of issues created\nfrom the puzzles in your code. In the example above, each time the appropriate\npush event is sent to your webhook up to 10 issues will be created regardless\nof the number of puzzles found in the code. If this limit is not set,\n`threshold` is assumed to be equal to 256.\n\nSection `errors` allows you to specify a list of email addresses that will\nreceive notifications when PDD processing fails for your repo. This is\na useful feature, since programmers often make\nmistakes in PDD puzzle formatting. We recommend using it.\n\nSection `alerts` allows you to specify users that will be notified when\nnew PDD puzzles show up. By default we will just submit GitHub tickets\nand that's it. If you add `github` subsection there, you can list GitHub\nusers who will be \"notified\": their GitHub nicknames will be added to\neach puzzle description and GitHub will notify them by email.\n\nSubsection `suppress` lets you make 0pdd quieter where necessary:\n\n* `on-found-puzzle`: stay quiet when a new puzzle is discovered\n\n* `on-lost-puzzle`: stay quiet when a puzzle is gone\n\n* `on-scope`: stay quiet when child puzzles change statuses\n\nThe `model` option is used by 0pdd\nto opt in to an ML model that prioritizes puzzles generated by `pdd`.\nIf you would like to opt in to puzzle prioritization, add this option\nto your `.0pdd.yml`.\n\n[pdd](https://github.com/yegor256/pdd) is the tool that parses your source\ncode files. You can configure its behavior by adding `.pdd` file to the\nroot directory of the repository. Take\n[this one](https://github.com/yegor256/0pdd/blob/master/.pdd), as an example.\n\nThe `format` section helps you instruct 0pdd about GitHub issues formatting.\nThese options are supported:\n\n* `short-title`: issue title will not include file name and line numbers\n\n* `title-length=...`: you may configure the length of the title of GitHub\nissues we create. Minimum length is 30, maximum is 255. Any other values\nwill be silently ignored. The default length is 60.\n\nThe `tags` section lists GitHub labels that will automatically be attached\nto all new issues we create. If you don't have those labels in your GitHub\nrepository, they will automatically be created.\n\nTo exclude files from analysis, create a `.pdd` file with the following content:\n\n```text\n--exclude=path/to/file.txt\n```\n\nSee: [pdd usage](https://github.com/cqfn/pdd?tab=readme-ov-file#usage)\n\n## What to expect?\n\nPay attention to the comments @0pdd posts to your commits. They will\ncontain valuable information about its recent actions. If something goes\nwrong, you will receive exception messages there. Please, post them here\nas new issues.\n\nRemember that GitHub triggers us only when you do `git push`. This means that\nif you make a number of commits, we will process them all together. Only the\nlatest one will be commented. It may not be the one with new puzzles though.\n\nAfter we create GitHub issues you can modify their titles and descriptions. You\ncan work with them as with any other issues. We will touch them only one\nmore time, when the puzzle disappears from the source code. At that moment\nwe will try to close the issue. If it is already closed, nothing will happen.\nHowever, it's not a good practice to close them manually. You better remove\nthe necessary puzzle from the source code and let us close the issue.\n\n## How to contribute?\n\nIt is a Ruby project.\nFirst, install\n[Java] SDK 8+,\n[Maven 3.2+](https://maven.apache.org/),\n[Ruby 2.3+](https://www.ruby-lang.org/en/documentation/installation/),\n[Rubygems](https://rubygems.org/pages/download),\nand\n[Bundler](https://bundler.io/).\nThen:\n\n```bash\nbundle update\nbundle exec rake\n```\n\nThe build has to be clean. If it's not,\n[submit an issue](https://github.com/yegor256/0pdd/issues).\n\nThen, make your changes, make sure the build is still clean,\nand [submit a pull request][guidelines].\n\nTo run it locally:\n\n```bash\nbundle exec rake run\n```\n\nIf you want to run it on your own machine, you will need to add this\n`config.yml` file to the root directory of this repository:\n\n```yaml\ns3:\n  region: us-east-1\n  bucket: xml.0pdd.com\n  key: AKIAI..........UTSQA\n  secret: Z2FbKB..........viCKaYo4H..........vva21\nsentry: https://....@sentry.io/229223\ndynamo:\n  region: us-east-1\n  key: AKIAI..........UTSQA\n  secret: Z2FbKB..........viCKaYo4H..........vva21\ngithub:\n  client_id: b96a3b5..........87e\n  client_secret: be61c471154e2..........66f434d33e0f63a5f\n  encryption_secret: some-random-text\n  login: 0pdd\n  token: GitHub-Password\nsmtp:\n  host: email-smtp.us-east-1.amazonaws.com\n  port: 587\n  user: smtp_user\n  password: smtp_password\nid_rsa: |\n  ... RSA key goes here, in ASCII format\n```\n\nWe add this file to the repository while deploying to Heroku,\nsee how it's done in `.rultor.yml`.\n\n## How to install in Heroku\n\nDon't forget this:\n\n```bash\nheroku buildpacks:add --index 1 https://github.com/heroku/heroku-buildpack-apt\n```\n\n[pdd.yml]: https://github.com/yegor256/0pdd/blob/master/.github/workflows/pdd.yml\n[guidelines]: https://www.yegor256.com/2014/04/15/github-guidelines.html\n[Java]: https://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html\n[collaborator]: https://help.github.com/articles/inviting-collaborators-to-a-personal-repository/\n"
  },
  {
    "path": "REUSE.toml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nversion = 1\n[[annotations]]\npath = [\n    \".DS_Store\",\n    \".gitattributes\",\n    \".gitignore\",\n    \".pdd\",\n    \"**.json\",\n    \"**.md\",\n    \"**.png\",\n    \"**.sigil\",\n    \"**.svg\",\n    \"**.txt\",\n    \"**/.DS_Store\",\n    \"**/.gitignore\",\n    \"**/.pdd\",\n    \"**/*.csv\",\n    \"**/*.jpg\",\n    \"**/*.json\",\n    \"**/*.md\",\n    \"**/*.pdf\",\n    \"**/*.png\",\n    \"**/*.svg\",\n    \"**/*.txt\",\n    \"**/*.vm\",\n    \"**/CNAME\",\n    \"**/Gemfile.lock\",\n    \"**/Procfile\",\n    \"Aptfile\",\n    \"doc/integration.puml\",\n    \"Gemfile.lock\",\n    \"nginx.conf.sigil\",\n    \"Procfile\",\n    \"README.md\",\n    \"renovate.json\",\n]\nprecedence = \"override\"\nSPDX-FileCopyrightText = \"Copyright (c) 2025 Yegor Bugayenko\"\nSPDX-License-Identifier = \"MIT\"\n"
  },
  {
    "path": "Rakefile",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'rubygems'\nrequire 'rake'\nrequire 'rake/clean'\nrequire_relative 'objects/dynamo'\n\nENV['RACK_ENV'] = 'test'\n\ntask default: %i[clean test rubocop xcop]\n\nrequire 'rake/testtask'\ndesc 'Run all unit tests'\nRake::TestTask.new(test: :dynamo) do |test|\n  Rake::Cleaner.cleanup_files(['coverage'])\n  test.libs << 'lib' << 'test'\n  test.pattern = 'test/**/test_*.rb'\n  test.verbose = false\n  test.warning = false\nend\n\nrequire 'rubocop/rake_task'\ndesc 'Run RuboCop on all directories'\nRuboCop::RakeTask.new(:rubocop) do |task|\n  task.fail_on_error = true\nend\n\nrequire 'xcop/rake_task'\ndesc 'Validate all XML/XSL/XSD/HTML files for formatting'\nXcop::RakeTask.new :xcop do |task|\n  task.includes = ['**/*.xml', '**/*.xsl', '**/*.xsd', '**/*.html']\n  task.excludes = ['target/**/*', 'coverage/**/*', 'vendor/**/*']\nend\n\ndesc 'Start DynamoDB Local server'\ntask :dynamo do\n  FileUtils.rm_rf('dynamodb-local/target')\n  pid = Process.spawn('mvn', 'install', '--quiet', chdir: 'dynamodb-local')\n  at_exit do\n    `kill -TERM #{pid}`\n    puts \"DynamoDB Local killed in PID #{pid}\"\n  end\n  begin\n    status = Dynamo.new.aws.describe_table(\n      table_name: '0pdd-events'\n    )[:table][:table_status]\n    puts \"DynamoDB Local table: #{status}\"\n  rescue Exception => e\n    puts e.message\n    sleep(5)\n    retry\n  end\n  puts \"DynamoDB Local is running in PID #{pid}\"\nend\n\ndesc 'Sleep endlessly after the start of DynamoDB Local server'\ntask :sleep do\n  loop do\n    sleep(5)\n    puts 'Still alive...'\n  end\nend\n\ndesc 'Run website'\ntask run: :dynamo do\n  `rerun -b \"RACK_ENV=test rackup\"`\nend\n"
  },
  {
    "path": "app.json",
    "content": "{\n  \"healthchecks\": {\n    \"web\": [\n      {\n        \"attempts\": 3,\n        \"description\": \"Checking if the app responds to the /robots.txt endpoint\",\n        \"name\": \"web check\",\n        \"path\": \"/robots.txt\",\n        \"type\": \"startup\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "assets/sass/main.sass",
    "content": "// SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n// SPDX-License-Identifier: MIT\n\nbody\n  background-color: white\n  color: #111\n  font-family: monospace\n  font-size: 18px\n  margin: 0\n  padding: 1em\n\na\n  color: blue\n  &:hover\n    color: inherit\n\n.logo\n  height: 92px\n  width: 92px\n\n.center\n  height: 19em\n  left: 0\n  margin: auto\n  max-width: 100%\n  position: absolute\n  right: 0\n  text-align: center\n  width: 20em\n\n.versions\n  img\n    height: 1em\n"
  },
  {
    "path": "assets/upgrades/add-namespace.xsl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<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\">\n  <xsl:output method=\"xml\"/>\n  <xsl:strip-space elements=\"*\"/>\n  <xsl:template match=\"/puzzles\">\n    <puzzles xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"https://www.0pdd.com/puzzles.xsd\">\n      <xsl:apply-templates select=\"node()|@*\"/>\n    </puzzles>\n  </xsl:template>\n  <xsl:template match=\"node()|@*\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"node()|@*\"/>\n    </xsl:copy>\n  </xsl:template>\n</xsl:stylesheet>\n"
  },
  {
    "path": "assets/upgrades/remove-broken-issues.xsl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<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\">\n  <xsl:output method=\"xml\"/>\n  <xsl:strip-space elements=\"*\"/>\n  <xsl:template match=\"puzzle/issue[string-length(.) = 0]\">\n    <!-- This issue is broken, we just don't copy it -->\n  </xsl:template>\n  <xsl:template match=\"puzzle/issue/@href[string-length(.) = 0]\">\n    <!-- This HREF is broken, we just don't copy it -->\n  </xsl:template>\n  <xsl:template match=\"node()|@*\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"node()|@*\"/>\n    </xsl:copy>\n  </xsl:template>\n</xsl:stylesheet>\n"
  },
  {
    "path": "assets/xsd/puzzles.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">\n  <xs:simpleType name=\"issue_name\">\n    <xs:restriction base=\"xs:string\">\n      <xs:pattern value=\"[0-9]+|[A-Z]+-[0-9]+|unknown\"/>\n    </xs:restriction>\n  </xs:simpleType>\n  <xs:complexType name=\"puzzle\">\n    <xs:all>\n      <xs:element name=\"id\" minOccurs=\"1\" maxOccurs=\"1\">\n        <xs:simpleType>\n          <xs:restriction base=\"xs:string\">\n            <xs:pattern value=\"[a-zA-Z0-9\\-]+-[a-f0-9]{8}\"/>\n          </xs:restriction>\n        </xs:simpleType>\n      </xs:element>\n      <xs:element name=\"issue\" minOccurs=\"0\" maxOccurs=\"1\">\n        <xs:complexType>\n          <xs:simpleContent>\n            <xs:extension base=\"issue_name\">\n              <xs:attribute name=\"model\" type=\"xs:integer\" use=\"optional\"/>\n              <xs:attribute name=\"href\" type=\"xs:anyURI\" use=\"optional\"/>\n              <xs:attribute name=\"closed\" use=\"optional\" type=\"xs:dateTime\"/>\n            </xs:extension>\n          </xs:simpleContent>\n        </xs:complexType>\n      </xs:element>\n      <xs:element name=\"body\" minOccurs=\"1\" maxOccurs=\"1\">\n        <xs:simpleType>\n          <xs:restriction base=\"xs:string\">\n            <xs:minLength value=\"1\"/>\n          </xs:restriction>\n        </xs:simpleType>\n      </xs:element>\n      <xs:element name=\"lines\" minOccurs=\"1\" maxOccurs=\"1\">\n        <xs:simpleType>\n          <xs:restriction base=\"xs:string\">\n            <xs:pattern value=\"[0-9]+-[0-9]+\"/>\n          </xs:restriction>\n        </xs:simpleType>\n      </xs:element>\n      <xs:element name=\"file\" minOccurs=\"1\" maxOccurs=\"1\">\n        <xs:simpleType>\n          <xs:restriction base=\"xs:string\">\n            <xs:pattern value=\".+\"/>\n          </xs:restriction>\n        </xs:simpleType>\n      </xs:element>\n      <xs:element name=\"estimate\" minOccurs=\"1\" maxOccurs=\"1\">\n        <xs:simpleType>\n          <xs:restriction base=\"xs:integer\">\n            <xs:minInclusive value=\"0\"/>\n            <xs:maxInclusive value=\"60000\"/>\n          </xs:restriction>\n        </xs:simpleType>\n      </xs:element>\n      <xs:element name=\"ticket\" minOccurs=\"1\" maxOccurs=\"1\">\n        <xs:simpleType>\n          <xs:restriction base=\"xs:string\">\n            <xs:pattern value=\"[a-zA-Z0-9\\-]+\"/>\n          </xs:restriction>\n        </xs:simpleType>\n      </xs:element>\n      <xs:element name=\"role\" minOccurs=\"1\" maxOccurs=\"1\">\n        <xs:simpleType>\n          <xs:restriction base=\"xs:string\">\n            <xs:pattern value=\"[A-Z]+\"/>\n          </xs:restriction>\n        </xs:simpleType>\n      </xs:element>\n      <xs:element name=\"author\" minOccurs=\"1\" maxOccurs=\"1\">\n        <xs:simpleType>\n          <xs:restriction base=\"xs:string\">\n            <xs:pattern value=\".+\"/>\n          </xs:restriction>\n        </xs:simpleType>\n      </xs:element>\n      <xs:element name=\"email\" minOccurs=\"1\" maxOccurs=\"1\">\n        <xs:simpleType>\n          <xs:restriction base=\"xs:string\">\n            <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})\"/>\n          </xs:restriction>\n        </xs:simpleType>\n      </xs:element>\n      <xs:element name=\"time\" minOccurs=\"1\" maxOccurs=\"1\" type=\"xs:dateTime\"/>\n      <xs:element name=\"children\" minOccurs=\"0\" maxOccurs=\"1\">\n        <xs:complexType>\n          <xs:sequence>\n            <xs:element name=\"puzzle\" type=\"puzzle\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n          </xs:sequence>\n        </xs:complexType>\n      </xs:element>\n    </xs:all>\n    <xs:attribute name=\"alive\" use=\"required\" type=\"xs:boolean\"/>\n  </xs:complexType>\n  <xs:element name=\"puzzles\">\n    <xs:complexType>\n      <xs:sequence>\n        <xs:element name=\"puzzle\" type=\"puzzle\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n      </xs:sequence>\n      <xs:attribute name=\"date\" use=\"required\" type=\"xs:dateTime\"/>\n      <xs:attribute name=\"model\" use=\"optional\" type=\"xs:boolean\"/>\n      <xs:attribute name=\"version\" use=\"required\">\n        <xs:simpleType>\n          <xs:restriction base=\"xs:string\">\n            <xs:pattern value=\"[0-9\\.]+|BUILD\"/>\n          </xs:restriction>\n        </xs:simpleType>\n      </xs:attribute>\n    </xs:complexType>\n    <xs:unique name=\"puzzleId\">\n      <xs:selector xpath=\".//puzzle\"/>\n      <xs:field xpath=\"@id\"/>\n    </xs:unique>\n  </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "assets/xsl/group.xsl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<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\">\n  <xsl:output method=\"xml\"/>\n  <xsl:strip-space elements=\"*\"/>\n  <xsl:key name=\"issues\" match=\"//puzzle\" use=\"issue\"/>\n  <xsl:key name=\"roots\" match=\"//puzzle[not(key('issues',ticket))]\" use=\"id\"/>\n  <xsl:template match=\"/puzzles\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"@*\"/>\n      <xsl:apply-templates select=\"//puzzle[key('roots',id)]\"/>\n    </xsl:copy>\n  </xsl:template>\n  <xsl:template match=\"puzzle\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"node()|@*\"/>\n      <children>\n        <xsl:apply-templates select=\"//puzzle[ticket=current()/issue]\"/>\n      </children>\n    </xsl:copy>\n  </xsl:template>\n  <xsl:template match=\"node()|@*\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"node()|@*\"/>\n    </xsl:copy>\n  </xsl:template>\n</xsl:stylesheet>\n"
  },
  {
    "path": "assets/xsl/join.xsl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<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\">\n  <xsl:output method=\"xml\"/>\n  <xsl:strip-space elements=\"*\"/>\n  <xsl:key name=\"existing\" match=\"//puzzle\" use=\"id\"/>\n  <xsl:key name=\"extras\" match=\"//extra\" use=\"id\"/>\n  <xsl:template match=\"/puzzles\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"@*\"/>\n      <xsl:apply-templates select=\"//puzzle[id!='unknown']\"/>\n      <xsl:apply-templates select=\"//extra[not(key('existing',id))]\"/>\n    </xsl:copy>\n  </xsl:template>\n  <xsl:template match=\"puzzle|extra\">\n    <puzzle>\n      <xsl:attribute name=\"alive\">\n        <xsl:choose>\n          <xsl:when test=\"key('extras',id)\">\n            <xsl:text>true</xsl:text>\n          </xsl:when>\n          <xsl:otherwise>\n            <xsl:text>false</xsl:text>\n          </xsl:otherwise>\n        </xsl:choose>\n      </xsl:attribute>\n      <xsl:choose>\n        <xsl:when test=\"issue\">\n          <xsl:apply-templates select=\"issue\"/>\n        </xsl:when>\n        <xsl:otherwise>\n          <issue>\n            <xsl:text>unknown</xsl:text>\n          </issue>\n        </xsl:otherwise>\n      </xsl:choose>\n      <xsl:apply-templates select=\"ticket|estimate|role|id|lines|body|file|author|email|time\"/>\n    </puzzle>\n  </xsl:template>\n  <xsl:template match=\"node()|@*\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"node()|@*\"/>\n    </xsl:copy>\n  </xsl:template>\n</xsl:stylesheet>\n"
  },
  {
    "path": "assets/xsl/puzzles.xsl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<xsl:stylesheet xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\" xmlns=\"http://www.w3.org/1999/xhtml\" version=\"1.0\">\n  <xsl:output method=\"xml\" omit-xml-declaration=\"yes\"/>\n  <xsl:param name=\"version\"/>\n  <xsl:param name=\"project\"/>\n  <xsl:param name=\"length\"/>\n  <xsl:template match=\"/puzzles\">\n    <html>\n      <head>\n        <meta charset=\"UTF-8\"/>\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>\n        <meta name=\"description\" content=\"{$project}\"/>\n        <meta name=\"keywords\" content=\"{$project}\"/>\n        <meta name=\"author\" content=\"0pdd.com\"/>\n        <title>\n          <xsl:value-of select=\"$project\"/>\n        </title>\n        <link type=\"text/css\" href=\"/css/main.css\" rel=\"stylesheet\"/>\n        <link rel=\"shortcut icon\" href=\"https://avatars2.githubusercontent.com/u/24456188\"/>\n      </head>\n      <body>\n        <p>\n          <a href=\"https://www.0pdd.com\">\n            <img class=\"logo\" src=\"https://avatars2.githubusercontent.com/u/24456188\"/>\n          </a>\n        </p>\n        <p>\n          <img src=\"/svg?name={$project}\"/>\n        </p>\n        <p>\n          <xsl:value-of select=\"count(//puzzle[@alive='true'])\"/>\n          <xsl:text> alive, </xsl:text>\n          <xsl:value-of select=\"count(//puzzle)\"/>\n          <xsl:text> total.</xsl:text>\n        </p>\n        <xsl:apply-templates select=\"puzzle\"/>\n        <p>\n          <xsl:text>--</xsl:text>\n        </p>\n        <p>\n          <xsl:text>Full </xsl:text>\n          <a href=\"/log?name={$project}\">\n            <xsl:text>log</xsl:text>\n          </a>\n          <xsl:text> of recent events.</xsl:text>\n        </p>\n        <p>\n          <xsl:text>Download </xsl:text>\n          <a href=\"/xml?name={$project}\">\n            <xsl:text>XML</xsl:text>\n          </a>\n          <xsl:text> (</xsl:text>\n          <span title=\"{$length} bytes\">\n            <xsl:value-of select=\"format-number($length div 1024, '#.0')\"/>\n            <xsl:text> Kb</xsl:text>\n          </span>\n          <xsl:text>); see </xsl:text>\n          <a href=\"/snapshot?name={$project}\">\n            <xsl:text>snapshot</xsl:text>\n          </a>\n          <xsl:text>.</xsl:text>\n        </p>\n        <p>\n          <xsl:text>Project \"</xsl:text>\n          <xsl:value-of select=\"$project\"/>\n          <xsl:text>\" updated by </xsl:text>\n          <a href=\"https://www.0pdd.com\">\n            <xsl:text>0pdd</xsl:text>\n          </a>\n          <xsl:text> v</xsl:text>\n          <xsl:value-of select=\"@version\"/>\n          <xsl:text> on </xsl:text>\n          <xsl:value-of select=\"@date\"/>\n          <xsl:text>.</xsl:text>\n        </p>\n        <p>\n          <a href=\"https://www.0pdd.com\" title=\"Current version of 0pdd is {$version}\">\n            <xsl:value-of select=\"$version\"/>\n          </a>\n        </p>\n      </body>\n    </html>\n  </xsl:template>\n  <xsl:template match=\"puzzle\">\n    <div>\n      <span>\n        <xsl:if test=\"@alive = 'false'\">\n          <xsl:attribute name=\"style\">\n            <xsl:text>color:gray;</xsl:text>\n          </xsl:attribute>\n        </xsl:if>\n        <xsl:apply-templates select=\"id\" mode=\"fonted\"/>\n        <xsl:text> </xsl:text>\n        <xsl:value-of select=\"file\"/>\n        <xsl:text>:</xsl:text>\n        <xsl:value-of select=\"lines\"/>\n        <xsl:text> </xsl:text>\n        <xsl:value-of select=\"estimate\"/>\n        <xsl:text>min </xsl:text>\n      </span>\n      <xsl:if test=\"children/puzzle\">\n        <div style=\"margin-left: 2em;\">\n          <xsl:apply-templates select=\"children/puzzle\"/>\n        </div>\n      </xsl:if>\n    </div>\n  </xsl:template>\n  <xsl:template match=\"id\" mode=\"fonted\">\n    <xsl:choose>\n      <xsl:when test=\"../@alive='true'\">\n        <xsl:apply-templates select=\".\" mode=\"linked\"/>\n      </xsl:when>\n      <xsl:otherwise>\n        <strike>\n          <xsl:apply-templates select=\".\" mode=\"linked\"/>\n        </strike>\n      </xsl:otherwise>\n    </xsl:choose>\n  </xsl:template>\n  <xsl:template match=\"id\" mode=\"linked\">\n    <xsl:choose>\n      <xsl:when test=\"../issue/@href\">\n        <a href=\"{../issue/@href}\" style=\"color:inherit\">\n          <xsl:value-of select=\".\"/>\n        </a>\n      </xsl:when>\n      <xsl:otherwise>\n        <xsl:value-of select=\".\"/>\n      </xsl:otherwise>\n    </xsl:choose>\n  </xsl:template>\n</xsl:stylesheet>\n"
  },
  {
    "path": "assets/xsl/svg.xsl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<xsl:stylesheet xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\" xmlns=\"http://www.w3.org/2000/svg\" version=\"1.0\">\n  <xsl:output method=\"xml\" omit-xml-declaration=\"yes\"/>\n  <xsl:template match=\"/puzzles\">\n    <xsl:variable name=\"alive\" select=\"count(//puzzle[@alive='true'])\"/>\n    <xsl:variable name=\"total\" select=\"count(//puzzle)\"/>\n    <xsl:variable name=\"count\" select=\"concat($alive, '/', $total)\"/>\n    <xsl:variable name=\"advance\" select=\"47 + (string-length($count) * 6.5) + 7\"/>\n    <xsl:variable name=\"width\">\n      <xsl:choose>\n        <xsl:when test=\"$advance &gt; 86\">\n          <xsl:value-of select=\"ceiling($advance)\"/>\n        </xsl:when>\n        <xsl:otherwise>\n          <xsl:text>86</xsl:text>\n        </xsl:otherwise>\n      </xsl:choose>\n    </xsl:variable>\n    <svg width=\"{$width}\" height=\"20\">\n      <linearGradient id=\"b\" x2=\"0\" y2=\"100%\">\n        <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\n        <stop offset=\"1\" stop-opacity=\".1\"/>\n      </linearGradient>\n      <mask id=\"a\">\n        <rect width=\"{$width}\" height=\"20\" rx=\"3\" fill=\"#fff\"/>\n      </mask>\n      <g mask=\"url(#a)\">\n        <path fill=\"#555\" d=\"M0 0h47v20H0z\"/>\n        <path fill=\"#4c1\" d=\"M47 0h{$width - 47}v20H47z\"/>\n        <path fill=\"url(#b)\" d=\"M0 0h{$width}v20H0z\"/>\n      </g>\n      <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">\n        <text x=\"19.5\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">0pdd</text>\n        <text x=\"19.5\" y=\"14\">0pdd</text>\n        <text x=\"{$width - 3.5}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\" text-anchor=\"end\">\n          <xsl:value-of select=\"$count\"/>\n        </text>\n        <text x=\"{$width - 3.5}\" y=\"14\" text-anchor=\"end\">\n          <xsl:value-of select=\"$count\"/>\n        </text>\n      </g>\n    </svg>\n  </xsl:template>\n</xsl:stylesheet>\n"
  },
  {
    "path": "assets/xsl/to-close.xsl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<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\">\n  <xsl:output method=\"xml\"/>\n  <xsl:strip-space elements=\"*\"/>\n  <xsl:key name=\"extras\" match=\"//extra\" use=\"id\"/>\n  <xsl:template match=\"/puzzles\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"//puzzle[@alive='true' and not(key('extras',id)) and issue!='unknown']\"/>\n    </xsl:copy>\n  </xsl:template>\n  <xsl:template match=\"node()|@*\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"node()|@*\"/>\n    </xsl:copy>\n  </xsl:template>\n</xsl:stylesheet>\n"
  },
  {
    "path": "assets/xsl/to-submit.xsl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<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\">\n  <xsl:output method=\"xml\"/>\n  <xsl:strip-space elements=\"*\"/>\n  <xsl:key name=\"existing\" match=\"//puzzle[@alive='true']\" use=\"id\"/>\n  <xsl:template match=\"/puzzles\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"//extra[not(key('existing',id))]\"/>\n    </xsl:copy>\n  </xsl:template>\n  <xsl:template match=\"extra\">\n    <puzzle>\n      <xsl:apply-templates select=\"@*|node()\"/>\n    </puzzle>\n  </xsl:template>\n  <xsl:template match=\"node()|@*\">\n    <xsl:copy>\n      <xsl:apply-templates select=\"node()|@*\"/>\n    </xsl:copy>\n  </xsl:template>\n</xsl:stylesheet>\n"
  },
  {
    "path": "config.ru",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire './0pdd'\n\n$stdout.sync = true\n\nrun Sinatra::Application\n"
  },
  {
    "path": "cucumber.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\ndefault: --format pretty\ntravis: --format progress\nhtml_report: --format progress --format html --out=features_report.html\n"
  },
  {
    "path": "deploy.sh",
    "content": "#!/usr/bin/env bash\n\n# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nset -e -o pipefail\n\ncd \"$(dirname \"$0\")\"\nbundle update\nsed -i -s 's|Gemfile.lock||g' .gitignore\ncp /code/home/assets/0pdd/config.yml .\ngit add config.yml\ngit add Gemfile.lock\ngit add .gitignore\ngit commit --no-verify -m 'config.yml for heroku'\ntrap 'git reset HEAD~1 && rm config.yml && git checkout -- .gitignore' EXIT\ngit push heroku master -f\n"
  },
  {
    "path": "dynamodb-local/config/dynamo.yml",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n---\nport: ${dynamo.port}\nkey: ${dynamo.key}\nsecret: ${dynamo.secret}\n"
  },
  {
    "path": "dynamodb-local/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<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\">\n  <modelVersion>4.0.0</modelVersion>\n  <groupId>com.0pdd</groupId>\n  <artifactId>dynamodb-local</artifactId>\n  <version>1.0-SNAPSHOT</version>\n  <packaging>pom</packaging>\n  <name>dynamodb-local</name>\n  <properties>\n    <dynamo.key>AAAAABBBBBAAAAABBBBB</dynamo.key>\n    <dynamo.secret>ABCDABCDABCDABCDABCDABCDABCDABCDABCDABCD</dynamo.secret>\n  </properties>\n  <build>\n    <plugins>\n      <plugin>\n        <artifactId>maven-dependency-plugin</artifactId>\n        <executions>\n          <execution>\n            <id>unpack-dynamodb-local</id>\n            <goals>\n              <goal>unpack</goal>\n            </goals>\n            <configuration>\n              <artifactItems>\n                <artifactItem>\n                  <groupId>com.jcabi</groupId>\n                  <artifactId>DynamoDBLocal</artifactId>\n                  <version>2023-05-26</version>\n                  <type>zip</type>\n                  <outputDirectory>${project.build.directory}/dynamodb-dist</outputDirectory>\n                  <overWrite>false</overWrite>\n                </artifactItem>\n              </artifactItems>\n            </configuration>\n          </execution>\n        </executions>\n      </plugin>\n      <plugin>\n        <groupId>org.codehaus.mojo</groupId>\n        <artifactId>build-helper-maven-plugin</artifactId>\n        <version>3.6.1</version>\n        <executions>\n          <execution>\n            <id>reserver-dynamodb-port</id>\n            <goals>\n              <goal>reserve-network-port</goal>\n            </goals>\n            <configuration>\n              <portNames>\n                <portName>dynamo.port</portName>\n              </portNames>\n            </configuration>\n          </execution>\n        </executions>\n      </plugin>\n      <plugin>\n        <artifactId>maven-resources-plugin</artifactId>\n        <version>3.5.0</version>\n        <executions>\n          <execution>\n            <id>copy-resources</id>\n            <phase>pre-integration-test</phase>\n            <goals>\n              <goal>copy-resources</goal>\n            </goals>\n            <configuration>\n              <outputDirectory>${project.build.directory}</outputDirectory>\n              <resources>\n                <resource>\n                  <directory>${basedir}/config</directory>\n                  <filtering>true</filtering>\n                </resource>\n              </resources>\n            </configuration>\n          </execution>\n        </executions>\n      </plugin>\n      <plugin>\n        <groupId>com.jcabi</groupId>\n        <artifactId>jcabi-dynamodb-maven-plugin</artifactId>\n        <version>0.10.1</version>\n        <executions>\n          <execution>\n            <id>dynamodb-integration-test</id>\n            <goals>\n              <goal>start</goal>\n              <goal>create-tables</goal>\n              <goal>wait</goal>\n            </goals>\n            <configuration>\n              <port>${dynamo.port}</port>\n              <dist>${project.build.directory}/dynamodb-dist</dist>\n              <key>${dynamo.key}</key>\n              <secret>${dynamo.secret}</secret>\n              <arguments>\n                <argument>-inMemory</argument>\n              </arguments>\n              <tables>\n                <table>${basedir}/tables/0pdd-events.json</table>\n              </tables>\n            </configuration>\n          </execution>\n        </executions>\n      </plugin>\n    </plugins>\n  </build>\n</project>\n"
  },
  {
    "path": "dynamodb-local/tables/0pdd-events.json",
    "content": "{\n  \"AttributeDefinitions\": [\n    {\n      \"AttributeName\": \"repo\",\n      \"AttributeType\": \"S\"\n    },\n    {\n      \"AttributeName\": \"time\",\n      \"AttributeType\": \"N\"\n    },\n    {\n      \"AttributeName\": \"tag\",\n      \"AttributeType\": \"S\"\n    }\n  ],\n  \"GlobalSecondaryIndexes\": [\n    {\n      \"IndexName\": \"tags\",\n      \"KeySchema\": [\n        {\n          \"AttributeName\": \"repo\",\n          \"KeyType\": \"HASH\"\n        },\n        {\n          \"AttributeName\": \"tag\",\n          \"KeyType\": \"RANGE\"\n        }\n      ],\n      \"Projection\": {\n        \"ProjectionType\": \"ALL\"\n      },\n      \"ProvisionedThroughput\": {\n        \"ReadCapacityUnits\": \"1\",\n        \"WriteCapacityUnits\": \"1\"\n      }\n    }\n  ],\n  \"KeySchema\": [\n    {\n      \"AttributeName\": \"repo\",\n      \"KeyType\": \"HASH\"\n    },\n    {\n      \"AttributeName\": \"time\",\n      \"KeyType\": \"RANGE\"\n    }\n  ],\n  \"ProvisionedThroughput\": {\n    \"ReadCapacityUnits\": \"1\",\n    \"WriteCapacityUnits\": \"1\"\n  },\n  \"TableName\": \"0pdd-events\"\n}\n"
  },
  {
    "path": "features/step_definitions/steps.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'tmpdir'\nrequire 'English'\n\nBefore do\n  @cwd = Dir.pwd\n  @dir = Dir.mktmpdir('test')\n  FileUtils.mkdir_p(@dir)\n  Dir.chdir(@dir)\nend\n\nAfter do\n  Dir.chdir(@cwd)\n  FileUtils.rm_rf(@dir)\nend\n"
  },
  {
    "path": "model/README.md",
    "content": "Puzzle Ranking (Linear ML Model)\n\n### Internals\n\nThe ML model is a linear model with PSO optimizer.\nThe optimizer is used to train the model on puzzle data,\nthe weights are stored and used to predict future puzzles.\n\nBecause of the time required, training is a non-blocking process,\nand puzzle prioritization uses a naive ranking approach based on puzzle estimate.\nSubsequent events use the linear model for prioritization.\n\nThe linear model is the external API for the model.\nIt has one method `predict(...)` which accepts an array of puzzles in xml.\nThe output of this model is an array of positional index of the input puzzles:\n\n```ruby\n# usage\n\nrank = LinearModel.new(repo_name, storage).predict(puzzles)\n\n# repo_name -> name of repository\n# storage -> storage object (with defined interface)\n# puzzles -> array of xml puzzles.\n#\n# rank -> array of positional index of ranked puzzles\n```\n\n### Integration\n\nThis diagram shows how this model can be integrated into 0pdd workflow:\n![integration.svg](../doc/integration.svg)\n"
  },
  {
    "path": "model/fake_weights_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# FakeWeightsStorage\n#\nclass FakeWeightsStorage\n  def initialize(\n    repo,\n    dir = Dir.mktmpdir\n  )\n    @file = File.join(dir, \"#{repo}.marshal\")\n  end\n\n  def load\n    # rubocop:disable Security/MarshalLoad\n    Marshal.load(File.read(@file)) if File.exist?(@file)\n    # rubocop:enable Security/MarshalLoad\n  end\n\n  def save(weights)\n    File.write(@file, Marshal.dump(weights))\n  end\nend\n"
  },
  {
    "path": "model/linear.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'json'\nrequire 'time'\nrequire 'crack'\nrequire_relative 'predictor'\nrequire_relative 'storage'\nrequire_relative 'fake_weights_storage'\n\n#\n# Linear Model\n# @todo #532:60min Add unit-tests.\n#  We should add unit-tests for this class that checks puzzle ranking.\n#  For now its untested, don't forget to remove this puzzle.\n#\nclass LinearModel\n  def initialize(repo, storage)\n    @repo = repo\n    @xml_storage = storage\n    if ENV['RACK_ENV'] == 'test'\n      @storage = FakeWeightsStorage.new(@repo)\n    else\n      settings = Sinatra::Application.settings\n      @storage = Storage.new(\n        \"#{@repo}.marshal\",\n        settings.config['s3']['bucket'],\n        settings.config['s3']['region'],\n        settings.config['s3']['key'],\n        settings.config['s3']['secret']\n      )\n    end\n  end\n\n  # ranks the puzzles using Machine-Learning\n  # @param puzzles XML puzzles\n  # @return array of positional index of the input puzzles\n  # @todo #532:60min Implement a ranked puzzles.\n  #  Let's implement a class that will use `LinearModel` to rank puzzles.\n  #  This class is need in order to do an integration between original 0pdd\n  #  and model modules. Probably it can be a decorator for `Puzzles`\n  #  that ranks XML puzzles, and then submits them into `Puzzles`.\n  #  Don't forget to remove this puzzle.\n  def predict(puzzles)\n    weights = @storage.load # load weights for repo from s3\n    clf = Predictor.new(\n      layers: [\n        { name: 'w1', shape: [10, 1] },\n        { name: 'w2', shape: [1, 1] }\n      ]\n    )\n    if weights.nil?\n      train(clf) # find weights for repo backlog of puzzles\n      ranks = naive_rank(puzzles) # naive rank of puzzles in each repo\n    else\n      # get x and y data for puzzles\n      samples, _labels = extract_features(puzzles)\n      ranks = clf.predict(weights, samples[0]) # model rank of puzzles if weights are loaded\n    end\n    ranks.map(&:to_i)\n  end\n\n  private\n\n  def replace_nil(arr, with = 0)\n    arr.map { |x| x.nil? ? with : x }\n  end\n\n  def get_features_labels(samples)\n    x = samples.map do |_, s|\n      replace_nil([\n        s['time_estimate'],\n        s['n_characters'],\n        s['level'],\n        s['n_puzzles_before'],\n        s['n_puzzles_after'],\n        s['time_before'],\n        s['time_after'],\n        s['n_additions'],\n        s['n_deletions']\n      ].append(s['vectorized_description']))\n    end\n    y = samples.map { |_, s| s['closed'] ? Time.parse(s['closed']).to_i : 0 }.map.with_index.sort.map(&:last)\n    [[x], [y]] # single backlog of puzzles\n  end\n\n  # depth first feature extraction\n  def extract_features(puzzles, samples = {}, level = 1)\n    puzzles = [puzzles] unless puzzles.is_a?(Array)\n    puzzles.each do |puzzle|\n      next if puzzle.nil?\n      prev_puzzle = samples[samples.keys.last]\n      time_before = 0\n      unless prev_puzzle.nil?\n        opened = Time.parse(prev_puzzle['time']).to_i\n        closed = prev_puzzle['closed'] ? Time.parse(prev_puzzle['closed']).to_i : opened\n        time_before = (closed - opened) / 60 # in minutes\n\n        unless prev_puzzle['time_after'].nil?\n          time_after = (Time.parse(puzzle['closed']).to_i - Time.parse(puzzle['time']).to_i) / 60 # in minutes\n          prev_puzzle['time_after'] = time_after\n        end\n      end\n      n_characters = puzzle['body'].gsub(/\\s/, '').length\n      samples[puzzle['id']] = {\n        'time_estimate' => puzzle['estimate'].to_i,\n        'n_characters' => n_characters,\n        'level' => level,\n        'n_puzzles_before' => samples.length,\n        'n_puzzles_after' => puzzles.length - samples.length,\n        'time_before' => time_before\n      }.merge(puzzle)\n\n      extract_features(puzzle['children']['puzzle'], samples, level + 1) unless puzzle['children'].nil?\n    end\n    get_features_labels(samples) if level == 1\n  end\n\n  def train(clf)\n    puzzles = @xml_storage.load\n    Thread.new do\n      # properly train model here and save weights to s3 for later\n      puzzles = JSON.parse(Crack::XML.parse(puzzles.to_s).to_json)['puzzles']\n      unless puzzles.nil?\n        samples, labels = extract_features(puzzles['puzzle'])\n        if labels[0].length > 1 # train only when there's data\n          center = ZeroVector.zero(samples[0][0].size)\n          solver = Pso::Solver.new(f: clf, center: center, data: samples, true_order: labels)\n          _rank, weights, _n_iterations = solver.solve\n          @storage.save(weights)\n        end\n      end\n    end\n  end\n\n  def naive_rank(puzzles)\n    estimates = puzzles.map { |puzzle| puzzle['estimate'].to_i }\n    estimates.map.with_index.sort.map(&:last)\n  end\nend\n"
  },
  {
    "path": "model/predictor.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'pso/pso'\n\ndef argsort(arr)\n  arr.map.with_index.sort.map(&:last)\nend\n\ndef normalised_kendall_tau_distance(a, b)\n  raise 'Both lists have to be of equal length' unless a.size == b.size\n  a = argsort(a)\n  b = argsort(b)\n  combination = a.combination(2)\n  disordered = 0\n  combination.each do |i, j|\n    is_disordered = (a[i] > a[j] && b[i] < b[j]) || (a[i] < a[j] && b[i] > b[j])\n    disordered += 1 if is_disordered\n  end\n  n = a.size\n  (2.0 * disordered.to_f) / (n * (n - 1.0))\nend\n\ndef default_option_generator_linear(attribute_num)\n  [\n    { layers: [{ name: 'w1', shape: [attribute_num, 1] }, { name: 'w2', shape: [1, 1] }] },\n    [attribute_num] + 1\n  ]\nend\n\n#\n# Linear Predictor Model\n#\nclass Predictor\n  def initialize(**options)\n    @layers = {}\n    @kendall_corr_history = []\n    options[:layers].each do |layer|\n      @layers[\"#{layer[:name]}_shape\"] = layer[:shape]\n    end\n  end\n\n  def f(weights, **options)\n    data = options[:data]\n    true_order = options[:true_order]\n    kns = []\n    (0...data.size).each do |i|\n      x = data[i]\n      y = true_order[i].first(x.size)\n      preds = predict(weights, x)\n      kn = normalised_kendall_tau_distance(preds, y)\n      kns.append(kn)\n    end\n    kns.sum / kns.size # mean\n  end\n\n  def train(weights, data, true_order)\n    ranks = predict(weights, data)\n    normalised_kendall_tau_distance(ranks, true_order)\n  end\n\n  def predict(weights, data)\n    ranks = []\n    (0...data.size).each do |i|\n      row = data[i]\n      r = forward_one(weights, row)\n      ranks.append(r)\n    end\n    ranks\n  end\n\n  def forward_one(weights, data)\n    x = data.clone.map(&:clone).flatten\n    w = weights.first(x.size)\n    x = Vector[*x].dot(Vector[*w])\n    w.map { |c| x += c }[0]\n  end\n\n  def kendall(weights, data, true_order)\n    x = predict(weights, data)\n    normalised_kendall_tau_distance(x, true_order)\n  end\nend\n"
  },
  {
    "path": "model/pso/lib/function.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'matrix'\n\nmodule Pso\n  #\n  # General Objective Function Interface\n  #\n  class Function\n    def f(vector, **_options)\n      vector.magnitude\n    end\n  end\nend\n"
  },
  {
    "path": "model/pso/lib/functions/rastrigin.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative '../function'\nrequire_relative '../zero_vector'\n\nmodule Pso\n  #\n  # Rastrigin Objective Function\n  #\n  class Rastrigin < Pso::Function\n    def f(vector, **_options)\n      fitness = 10 * vector.size\n      fitness + vector.sum { |n| (n**2) - (10 * Math.cos(2 * Math::PI * n)) }\n    end\n  end\nend\n"
  },
  {
    "path": "model/pso/lib/functions/schwefel.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative '../function'\nrequire_relative '../zero_vector'\n\nmodule Pso\n  #\n  # Schwefel Objective Function\n  #\n  class Schwefel < Pso::Function\n    def f(vector, **_options)\n      alpha = 418.982887\n      vector.sum { |n| -n * Math.sin(Math.sqrt(n.to_f.abs)) } + (alpha * vector.size)\n    end\n  end\nend\n"
  },
  {
    "path": "model/pso/lib/solver.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'zero_vector'\nrequire_relative 'functions/rastrigin'\n\n# rubocop:disable Metrics/ParameterLists\nmodule Pso\n  #\n  # PSO Solver\n  #\n  class Solver\n    def initialize(\n      din: 5,\n      density: 50,\n      f: Pso::Rastrigin,\n      center: nil,\n      radius: 5.12,\n      method: :min_by,\n      **options\n    )\n      begin\n        @f = f.new\n      rescue NoMethodError\n        @f = f\n      end\n      @din = din\n      @center = center\n      @radius = radius\n      @method = method\n      @density = density\n      @options = options\n\n      generate_swarm\n    end\n\n    def generate_swarm\n      Array.new(@density)\n      @swarm = Array.new(@density) { generate_random_particle }\n      @swarm_best = @swarm.map { |particle| [@f.f(particle, **@options), particle] }\n      @swarm_speed = @swarm.map { generate_random_particle }\n    end\n\n    def generate_random_noise_particle\n      @center.map { (rand * 2) - 1 }\n    end\n\n    def generate_random_particle\n      @center + (generate_random_noise_particle * (@radius * rand))\n    end\n\n    def perfect_particle\n      if @method == :min_by\n        @swarm.min_by do |element|\n          @f.f(element, **@options)\n        end\n      else\n        @swarm.max_by do |element|\n          @f.f(element, **@options)\n        end\n      end\n    end\n\n    def solve(precision: 100, threads: 1, debug: false)\n      n_iterations = 0\n      Array.new(threads).map do\n        Thread.new do\n          ((precision / @swarm.size) / threads).times do |_|\n            n_iterations += 1\n            (0...@density).each do |index|\n              perfect = perfect_particle\n              puts @f.f(perfect, **@options) if debug\n              new_vector = normalize(iterate(@swarm[index], @swarm_best[index].last, perfect, @swarm_speed[index]))\n              if best?(@swarm_best[index].first, @f.f(new_vector, **@options))\n                @swarm_best[index] = [@f.f(new_vector, **@options), new_vector]\n              end\n              @swarm_speed[index] = (new_vector - @swarm[index]).normalize\n              @swarm[index] = new_vector\n            end\n          end\n        end\n      end.each(&:join)\n\n      perfect = perfect_particle\n      [@f.f(perfect, **@options), perfect, n_iterations]\n    end\n\n    private\n\n    def best?(best, now)\n      if @method == :min_by\n        now < best\n      else\n        now > best\n      end\n    end\n\n    def normalize(vector)\n      return ((vector - @center).normalize * @radius) + @center if (vector - @center).magnitude > @radius\n      vector\n    end\n\n    def iterate(vector, best, perfect, speed)\n      if vector == perfect\n        out = generate_random_noise_particle\n        new_vec = vector + ((best - vector).normalize * 0.2) + (out * rand * 0.05) + (speed * 0.05)\n        minimal = @f.f(vector, **@options) > @f.f(new_vec, **@options)\n        return minimal ? new_vec : vector if @method == :min_by\n        return minimal ? vector : new_vec unless @method == :min_by\n      end\n      out = generate_random_noise_particle\n      vector + (out * rand * 0.1) + ((best - vector).normalize * 0.5) + (perfect - vector).normalize + speed\n    end\n  end\nend\n# rubocop:enable Metrics/ParameterLists\n"
  },
  {
    "path": "model/pso/lib/version.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nmodule Pso\n  VERSION = '0.1.1'.freeze\nend\n"
  },
  {
    "path": "model/pso/lib/zero_vector.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'matrix'\n\n#\n# Zero vector class\n#\nclass ZeroVector < Vector\n  def normalize\n    return self if zero?\n    super\n  end\nend\n"
  },
  {
    "path": "model/pso/pso.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'lib/version'\nrequire_relative 'lib/solver'\n\n#\n# PSO Module\n#\nmodule Pso\nend\n"
  },
  {
    "path": "model/storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'json'\nrequire 'aws-sdk-s3'\nrequire_relative '../version'\n\n#\n# S3 storage.\n#\nclass Storage\n  def initialize(ocket, bucket, region, key, secret)\n    @object = Aws::S3::Resource.new(\n      region: region,\n      credentials: Aws::Credentials.new(key, secret)\n    ).bucket(bucket).object(ocket)\n  end\n\n  def load\n    return unless @object.exists?\n    data = @object.get.body\n    puts \"S3 #{data.size} from #{@object.bucket_name}/#{@object.key}\"\n    # rubocop:disable Security/MarshalLoad\n    Marshal.load(data)\n    # rubocop:enable Security/MarshalLoad\n  end\n\n  def save(weights)\n    data = Marshal.dump(weights)\n    @object.put(body: data)\n    puts \"S3 #{data.size} to #{@object.bucket_name}/#{@object.key}\"\n  end\nend\n"
  },
  {
    "path": "nginx.conf.sigil",
    "content": "{{ range $port_map := .PROXY_PORT_MAP | split \" \" }}\n{{ $port_map_list := $port_map | split \":\" }}\n{{ $scheme := index $port_map_list 0 }}\n{{ $listen_port := index $port_map_list 1 }}\n{{ $upstream_port := index $port_map_list 2 }}\n\n{{ if eq $scheme \"http\" }}\nserver {\n  listen      [::]:{{ $listen_port }};\n  listen      {{ $listen_port }};\n  {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }}\n  access_log  {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-access.log;\n  error_log   {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-error.log;\n  location    / {\n\n    gzip on;\n    gzip_min_length  1100;\n    gzip_buffers  4 32k;\n    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;\n    gzip_vary on;\n    gzip_comp_level  6;\n\n    proxy_pass  http://{{ $.APP }}-{{ $upstream_port }};\n    proxy_http_version 1.1;\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header Connection $http_connection;\n    proxy_set_header Host $http_host;\n    proxy_set_header X-Forwarded-Proto $scheme;\n    proxy_set_header X-Forwarded-For $remote_addr;\n    proxy_set_header X-Forwarded-Port $server_port;\n    proxy_set_header X-Request-Start $msec;\n  }\n  include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf;\n\n  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;\n  location /400-error.html {\n    root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors;\n    internal;\n  }\n\n  error_page 404 /404-error.html;\n  location /404-error.html {\n    root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors;\n    internal;\n  }\n\n  error_page 500 501 502 503 504 505 506 507 508 509 510 511 /500-error.html;\n  location /500-error.html {\n    root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors;\n    internal;\n  }\n}\n{{ else if eq $scheme \"https\"}}\nserver {\n  listen      [::]:{{ $listen_port }} ssl {{ if eq $.HTTP2_SUPPORTED \"true\" }}http2{{ else if eq $.SPDY_SUPPORTED \"true\" }}spdy{{ end }};\n  listen      {{ $listen_port }} ssl {{ if eq $.HTTP2_SUPPORTED \"true\" }}http2{{ else if eq $.SPDY_SUPPORTED \"true\" }}spdy{{ end }};\n  {{ if $.SSL_SERVER_NAME }}server_name {{ $.SSL_SERVER_NAME }}; {{ end }}\n  {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }}\n  access_log  {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-access.log;\n  error_log   {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-error.log;\n\n  ssl_certificate           {{ $.APP_SSL_PATH }}/server.crt;\n  ssl_certificate_key       {{ $.APP_SSL_PATH }}/server.key;\n  ssl_protocols             TLSv1.2 {{ if eq $.TLS13_SUPPORTED \"true\" }}TLSv1.3{{ end }};\n  ssl_prefer_server_ciphers off;\n\n  keepalive_timeout   70;\n  {{ if and (eq $.SPDY_SUPPORTED \"true\") (ne $.HTTP2_SUPPORTED \"true\") }}add_header          Alternate-Protocol  {{ $.PROXY_SSL_PORT }}:npn-spdy/2;{{ end }}\n\n  location    / {\n\n    gzip on;\n    gzip_min_length  1100;\n    gzip_buffers  4 32k;\n    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;\n    gzip_vary on;\n    gzip_comp_level  6;\n\n    proxy_pass  http://{{ $.APP }}-{{ $upstream_port }};\n    {{ if eq $.HTTP2_PUSH_SUPPORTED \"true\" }}http2_push_preload on; {{ end }}\n    proxy_http_version 1.1;\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header Connection $http_connection;\n    proxy_set_header Host $http_host;\n    proxy_set_header X-Forwarded-Proto $scheme;\n    proxy_set_header X-Forwarded-For $remote_addr;\n    proxy_set_header X-Forwarded-Port $server_port;\n    proxy_set_header X-Request-Start $msec;\n  }\n  include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf;\n\n  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;\n  location /400-error.html {\n    root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors;\n    internal;\n  }\n\n  error_page 404 /404-error.html;\n  location /404-error.html {\n    root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors;\n    internal;\n  }\n\n  error_page 500 501 503 504 505 506 507 508 509 510 511 /500-error.html;\n  location /500-error.html {\n    root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors;\n    internal;\n  }\n\n  error_page 502 /502-error.html;\n  location /502-error.html {\n    root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors;\n    internal;\n  }\n}\n{{ else if eq $scheme \"grpc\"}}\n{{ if eq $.GRPC_SUPPORTED \"true\"}}{{ if eq $.HTTP2_SUPPORTED \"true\"}}\nserver {\n  listen      [::]:{{ $listen_port }} http2;\n  listen      {{ $listen_port }} http2;\n  {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }}\n  access_log  {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-access.log;\n  error_log   {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-error.log;\n  location    / {\n    grpc_pass  grpc://{{ $.APP }}-{{ $upstream_port }};\n  }\n  include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf;\n}\n{{ end }}{{ end }}\n{{ else if eq $scheme \"grpcs\"}}\n{{ if eq $.GRPC_SUPPORTED \"true\"}}{{ if eq $.HTTP2_SUPPORTED \"true\"}}\nserver {\n  listen      [::]:{{ $listen_port }} ssl http2;\n  listen      {{ $listen_port }} ssl http2;\n  {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }}\n  access_log  {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-access.log;\n  error_log   {{ $.NGINX_LOG_ROOT }}/{{ $.APP }}-error.log;\n\n  ssl_certificate           {{ $.APP_SSL_PATH }}/server.crt;\n  ssl_certificate_key       {{ $.APP_SSL_PATH }}/server.key;\n  ssl_protocols             TLSv1.2 {{ if eq $.TLS13_SUPPORTED \"true\" }}TLSv1.3{{ end }};\n  ssl_prefer_server_ciphers off;\n\n  location    / {\n    grpc_pass  grpc://{{ $.APP }}-{{ $upstream_port }};\n  }\n  include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf;\n}\n{{ end }}{{ end }}\n{{ end }}\n{{ end }}\n\n{{ if $.DOKKU_APP_LISTENERS }}\n{{ range $upstream_port := $.PROXY_UPSTREAM_PORTS | split \" \" }}\nupstream {{ $.APP }}-{{ $upstream_port }} {\n{{ range $listeners := $.DOKKU_APP_LISTENERS | split \" \" }}\n{{ $listener_list := $listeners | split \":\" }}\n{{ $listener_ip := index $listener_list 0 }}\n  server {{ $listener_ip }}:{{ $upstream_port }};{{ end }}\n}\n{{ end }}{{ end }}\n"
  },
  {
    "path": "objects/clients/github.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'octokit'\n\n#\n# Github client\n# API: http://octokit.github.io/octokit.rb/method_list.html\n#\nclass Github\n  def initialize(config = {})\n    @config = config\n  end\n\n  def client\n    if @config['testing']\n      require_relative '../../test/fake_github'\n      FakeGithub.new\n    else\n      args = {}\n      args[:access_token] = @config['github']['token'] if @config['github']\n      Octokit.connection_options = {\n        request: {\n          timeout: 20,\n          open_timeout: 20\n        }\n      }\n      Octokit.auto_paginate = true\n      Octokit::Client.new(args)\n    end\n  end\nend\n"
  },
  {
    "path": "objects/clients/gitlab.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'gitlab'\n\n#\n# Gitlab client\n# API: https://github.com/NARKOZ/gitlab\n#\nclass GitlabClient\n  def initialize(config = {})\n    @config = config\n  end\n\n  def client\n    if @config['testing']\n      require_relative '../../test/fake_gitlab'\n      FakeGitlab.new\n    else\n      token = @config['gitlab']['token'] if @config['gitlab']\n      Gitlab.client(\n        endpoint: 'https://gitlab.com/api/v4',\n        private_token: token,\n        httparty: {\n          headers: { 'Cookie' => 'gitlab_canary=true' }\n        }\n      )\n    end\n  end\nend\n"
  },
  {
    "path": "objects/clients/jira.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'rubygems'\nrequire 'jira-ruby'\n\n#\n# Jira client\n# API: https://github.com/sumoheavy/jira-ruby\n#\nclass JiraClient\n  def initialize(config = {})\n    @config = config\n  end\n\n  def client\n    if @config['testing']\n      # require_relative '../../test/fake_jira'\n      # FakeJira.new\n    else\n      username = @config['jira']['username'] if @config['jira']\n      token = @config['jira']['token'] if @config['jira']\n      options = {\n        username: username,\n        password: token,\n        site: 'http://localhost:8080/', # or 'https://<your_subdomain>.atlassian.net/' # often blank\n        auth_type: :basic,\n        read_timeout: 120\n      }\n      JIRA::Client.new(options)\n    end\n  end\nend\n"
  },
  {
    "path": "objects/diff.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\n\n#\n# Diff.\n#\nclass Diff\n  def initialize(before, after)\n    @before = before\n    @after = after\n  end\n\n  def notify(tickets)\n    @after.xpath('//puzzle/ticket/text()').map(&:to_s).uniq.each do |t|\n      current = summary(@after, t)\n      previous = summary(@before, t)\n      next if previous == current\n      next if current.empty?\n      tickets.notify(t, \"#{current}.\")\n    end\n  end\n\n  private\n\n  def issues(xml, *xpath)\n    xpath.map { |x| xml.xpath(x) }.flatten.map do |p|\n      issue = p.xpath('issue')\n      if issue.empty?\n        \"`#{p.xpath('id')}`\"\n      else\n        number = issue[0].text\n        link = issue[0]['href']\n        number = link.split('/')[-1] if link && number == 'unknown'\n        \"[##{number}](#{link})\"\n      end\n    end.sort\n  end\n\n  def summary(xml, ticket)\n    all = issues(\n      xml,\n      \"//puzzle[ticket='#{ticket}']/children//puzzle\",\n      \"//puzzle[ticket='#{ticket}']\"\n    )\n    alive = issues(\n      xml,\n      \"//puzzle[ticket='#{ticket}']/children//puzzle[@alive='true']\",\n      \"//puzzle[ticket='#{ticket}' and @alive='true']\"\n    )\n    if alive.empty?\n      if all.empty?\n        ''\n      elsif all.length == 1\n        \"the only puzzle #{all[0]} is solved here\"\n      else\n        \"all #{all.length} puzzles are solved here: #{all.join(', ')}\"\n      end\n    else\n      solved = all - alive\n      tail = solved.empty? ? '' : \"; solved: #{solved.join(', ')}\"\n      if alive.length == 1\n        \"the puzzle #{alive[0]} is still not solved\"\n      else\n        \"#{alive.length} puzzles #{alive.join(', ')} are still not solved\"\n      end + tail\n    end\n  end\nend\n"
  },
  {
    "path": "objects/dynamo.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'yaml'\nrequire 'aws-sdk-dynamodb'\n\n#\n# Dynamo client\n#\nclass Dynamo\n  def initialize(config = {})\n    @config = config\n  end\n\n  def aws\n    Aws::DynamoDB::Client.new(\n      if ENV['RACK_ENV'] == 'test'\n        cfg = File.join(Dir.pwd, 'dynamodb-local/target/dynamo.yml')\n        raise 'Test config is absent' unless File.exist?(cfg)\n        yaml = YAML.safe_load(File.open(cfg))\n        {\n          region: 'us-east-1',\n          endpoint: \"http://localhost:#{yaml['port']}\",\n          access_key_id: yaml['key'],\n          secret_access_key: yaml['secret'],\n          http_open_timeout: 5,\n          http_read_timeout: 5\n        }\n      else\n        {\n          region: @config['dynamo']['region'],\n          access_key_id: @config['dynamo']['key'],\n          secret_access_key: @config['dynamo']['secret']\n        }\n      end\n    )\n  end\nend\n"
  },
  {
    "path": "objects/git_repo.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'base64'\nrequire 'fileutils'\nrequire 'pdd'\nrequire 'qbash'\nrequire 'shellwords'\nrequire 'tempfile'\nrequire 'tmpdir'\nrequire 'yaml'\nrequire_relative 'user_error'\n\n#\n# Repository in Git\n#\nclass GitRepo\n  attr_reader :uri, :name, :path, :master, :head_commit_hash, :target\n\n  def initialize(\n    uri:,\n    name:,\n    master: 'master',\n    head_commit_hash: '',\n    **options\n  )\n    @id = Base64.encode64(uri).gsub(%r{[\\s=/]+}, '')\n    @name = name\n    @dir = options[:dir] || Dir.mktmpdir('0pdd')\n    @path = \"#{@dir}/#{@id}\"\n    @uri = uri\n    @id_rsa = options[:id_rsa] || ''\n    @master = master\n    @head_commit_hash = head_commit_hash\n    @target = options[:target] || 'master'\n  end\n\n  def lock\n    \"/tmp/0pdd-locks/#{@id}.txt\"\n  end\n\n  def config\n    f = File.join(@path, '.0pdd.yml')\n    if File.exist?(f)\n      YAML.safe_load(File.open(f))\n    else\n      {}\n    end\n  end\n\n  def xml\n    raise \"Path is absent: #{@path}\" unless File.exist?(@path)\n    Tempfile.open do |f|\n      begin\n        qbash(\"cd #{Shellwords.escape(@path)} && pdd -v -f #{Shellwords.escape(f.path)}\")\n      rescue StandardError => e\n        raise UserError, e.message\n      end\n      Nokogiri::XML(File.read(f))\n    end\n  end\n\n  def push\n    if File.exist?(@path)\n      pull\n    else\n      clone\n    end\n  end\n\n  def change_in_master?\n    \"refs/heads/#{master}\".eql?(target)\n  end\n\n  private\n\n  def clone\n    prepare_key\n    prepare_git\n    qbash(['git clone', '--depth=1', '--quiet', Shellwords.escape(@uri), Shellwords.escape(@path)])\n  end\n\n  def pull\n    prepare_key\n    prepare_git\n    qbash(\n      [\n        \"cd #{Shellwords.escape(@path)}\",\n        \"master=#{Shellwords.escape(@master)}\",\n        'git config --local core.autocrlf false',\n        'git reset origin/${master} --hard --quiet',\n        'git clean --force -d',\n        'git fetch --quiet',\n        'git checkout origin/${master}',\n        'git rebase --abort || true',\n        'git rebase --autostash --strategy-option=theirs origin/${master}'\n      ].join(' && ')\n    )\n  end\n\n  def prepare_key\n    dir = \"#{Dir.home}/.ssh\"\n    return if File.exist?(dir)\n    FileUtils.mkdir_p(dir)\n    File.write(\"#{dir}/id_rsa\", @id_rsa) unless @id_rsa.empty?\n    qbash(\n      [\n        'echo \"Host *\" > ~/.ssh/config',\n        'echo \"  StrictHostKeyChecking no\" >> ~/.ssh/config',\n        'echo \"  UserKnownHostsFile=~/.ssh/known_hosts\" >> ~/.ssh/config',\n        'chmod -R 600 ~/.ssh/*'\n      ].join(';')\n    )\n  end\n\n  def prepare_git\n    qbash(\n      [\n        'GIT=$(git --version)',\n        'if [[ \"${GIT}\" != \"git version 2.\"* ]]',\n        'then echo \"Git is too old: ${GIT}\"',\n        'exit -1',\n        'fi'\n      ].join(';')\n    )\n    return if ENV['RACK_ENV'] == 'test'\n    qbash(\n      [\n        'if ! git config --get --global user.email',\n        'then git config --global user.email \"server@0pdd.com\"',\n        'fi',\n        'if ! git config --get --global user.name',\n        'then git config --global user.name \"0pdd.com\"',\n        'fi'\n      ].join(';')\n    )\n  end\nend\n"
  },
  {
    "path": "objects/invitations/github_invitations.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Invitations in Github\n#\nclass GithubInvitations\n  def initialize(github)\n    @github = github\n  end\n\n  def accept\n    @github.user_repository_invitations.each do |i|\n      break if @github.rate_limit.remaining < 1000\n      puts \"Repository invitation #{i['id']} accepted\" if @github.accept_repository_invitation(i['id'])\n    end\n  end\n\n  def accept_single_invitation(repo)\n    invitations = @github.user_repository_invitations(repo: repo)\n    invitations.map do |i|\n      break if @github.rate_limit.remaining < 1000\n      \"Repository invitation #{repo} accepted\" if @github.accept_repository_invitation(i['id'])\n    end\n  end\n\n  def accept_orgs\n    @github.organization_memberships('state' => 'pending').each do |m|\n      break if @github.rate_limit.remaining < 1000\n      org = m['organization']['login']\n      begin\n        @github.update_organization_membership(org, 'state' => 'active')\n        puts \"Invitation for @#{org} accepted\"\n      rescue Octokit::NotFound\n        # puts \"Failed to join @#{org} organization: #{e.message}\"\n        @github.remove_organization_membership(org)\n        # puts \"Membership in @#{org} organization removed\"\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "objects/invitations/github_organization_invitations.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'github_organization_invitation'\n\n#\n# Invitations to join Github organizations\n#\nclass GithubOrganizationInvitations\n  def initialize(github)\n    @github = github\n  end\n\n  def all\n    @github.organization_memberships(state: 'pending').collect do |membership|\n      GithubOrganizationInvitation.new(membership, @github)\n    end\n  end\nend\n"
  },
  {
    "path": "objects/jobs/job.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'mail'\nrequire_relative '../diff'\nrequire_relative '../puzzles'\n\n#\n# One job.\n#\nclass Job\n  def initialize(vcs, storage, tickets)\n    @vcs = vcs\n    @storage = storage\n    @tickets = tickets\n  end\n\n  def proceed\n    @vcs.repo.push\n    before = @storage.load\n    Puzzles.new(@vcs.repo, @storage).deploy(@tickets)\n    return if opts.include?('on-scope')\n    Diff.new(before, @storage.load).notify(@tickets)\n  end\n\n  private\n\n  def opts\n    array = @vcs.repo.config.dig('alerts', 'suppress')\n    array.nil? || !array.is_a?(Array) ? [] : array\n  end\nend\n"
  },
  {
    "path": "objects/jobs/job_commiterrors.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative '../truncated'\n\n#\n# Job that posts exceptions as commit messages.\n#\nclass JobCommitErrors\n  def initialize(vcs, job)\n    @vcs = vcs\n    @job = job\n  end\n\n  def proceed\n    @job.proceed\n  rescue Exception => e\n    done = @vcs.create_commit_comment(\n      @vcs.repo.head_commit_hash,\n      \"I wasn't able to retrieve PDD puzzles from the code base and \\\nsubmit them to #{@vcs.name}. If you \\\nthink that it's a bug on our side, please submit it to \\\n[yegor256/0pdd](https://github.com/yegor256/0pdd/issues):\\n\\n\\\n> #{Truncated.new(e.message.gsub(/\\s/, ' '), 300)}\\n\\n\nPlease, copy and paste this stack trace to GitHub:\\n\\n\n```\\n#{e.class.name}\\n#{e.message}\\n#{e.backtrace.join(\"\\n\")}\\n```\"\n    )\n    puts \"Comment posted about an error: #{done[:html_url]}\"\n    raise e\n  end\nend\n"
  },
  {
    "path": "objects/jobs/job_detached.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'fileutils'\n\n#\n# One job.\n#\nclass JobDetached\n  def initialize(vcs, job)\n    @vcs = vcs\n    @job = job\n  end\n\n  def proceed\n    if ENV['RACK_ENV'] == 'test'\n      exclusive\n    else\n      Process.detach(fork { exclusive })\n    end\n  end\n\n  private\n\n  def exclusive\n    lock = @vcs.repo.lock\n    FileUtils.mkdir_p(File.dirname(lock))\n    f = File.open(lock, File::RDWR | File::CREAT, 0o644)\n    f.flock(File::LOCK_EX)\n    begin\n      @job.proceed\n    ensure\n      f.close\n      begin\n        File.delete(lock)\n      rescue Errno::EACCES\n        lock.close\n        File.delete(lock)\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "objects/jobs/job_emailed.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'mail'\n\n#\n# Job that emails if exception occurs.\n#\nclass JobEmailed\n  def initialize(vcs, job)\n    @vcs = vcs\n    @job = job\n  end\n\n  def proceed\n    @job.proceed\n  rescue Exception => e\n    yaml = @vcs.repo.config\n    emails = yaml['errors'] || []\n    emails << 'admin@0pdd.com'\n    trace = \"#{e.message}\\n\\n#{e.backtrace.join(\"\\n\")}\"\n    name = @vcs.repo.name\n    repo_owner_login = repo_user_login\n    repo_owner_email = user_email(repo_owner_login)\n    repository_link = @vcs.repository_link\n    emails.each do |email|\n      mail = Mail.new do\n        from '0pdd <no-reply@0pdd.com>'\n        to email\n        subject \"#{name}: puzzles discovery problem\"\n        text_part do\n          content_type 'text/plain; charset=UTF-8'\n          body \"Hey,\\n\\n\\\nThere is a problem in #{repository_link}:\\n\\n\\\n#{trace}\\n\\n\\\nIf you think it's our bug, please submit it to GitHub: \\\nhttps://github.com/yegor256/0pdd/issues\\n\\n\\\nSorry,\\n\\\n0pdd\"\n        end\n        html_part do\n          content_type 'text/html; charset=UTF-8'\n          body \"<html><body><p>Hey,</p>\n            <p>There is a problem in\n            <a href='#{repository_link}'>#{name}</a>:</p>\n            <pre>#{trace}</pre>\n            <p>If you think it's our bug, please submit it to\n            <a href='https://github.com/yegor256/0pdd/issues'>GitHub</a>.\n            Thanks.</p>\n            <p>Sorry,<br/><a href='https://www.0pdd.com'>0pdd</a></p>\"\n        end\n      end\n      mail.cc = repo_owner_email if repo_owner_email\n      mail.deliver!\n      puts \"Email sent to #{email}\"\n    end\n    raise e\n  end\n\n  private\n\n  def repo_user_login\n    @vcs.repo.name.split('/').first\n  end\n\n  def user_email(username)\n    @vcs.user(username)[:email]\n  end\nend\n"
  },
  {
    "path": "objects/jobs/job_recorded.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Job that records all requests.\n#\nclass JobRecorded\n  def initialize(vcs, job)\n    @vcs = vcs\n    @job = job\n  end\n\n  def proceed\n    @job.proceed\n    open('/tmp/0pdd-done.txt', 'a+') do |f|\n      f.puts(@vcs.repo.name)\n    end\n  end\nend\n"
  },
  {
    "path": "objects/jobs/job_starred.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Job that stars the repo.\n# API: http://octokit.github.io/octokit.rb/method_list.html\n#\nclass JobStarred\n  def initialize(vcs, job)\n    @vcs = vcs\n    @job = job\n  end\n\n  def proceed\n    output = @job.proceed\n    @vcs.star\n    output\n  end\nend\n"
  },
  {
    "path": "objects/log.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'base64'\nrequire 'nokogiri'\nrequire 'aws-sdk-dynamodb'\nrequire_relative 'dynamo'\nrequire_relative '../version'\n\n#\n# Log.\n#\nclass Log\n  def initialize(dynamo, repo, vcs = 'github')\n    @dynamo = dynamo\n    # @todo #312:30min Be sure to handle the use case where projects from\n    #  different vcs have the same <user/repo_name>. This will cause a conflict.\n    @vcs = (vcs || 'github').downcase\n    @repo = @vcs == 'github' ? repo : Base64.encode64(repo + @vcs).gsub(%r{[\\s=/]+}, '')\n\n    raise 'You need to specify your cloud VCS' unless ['github'].include?(@vcs)\n  end\n\n  def put(tag, text)\n    @dynamo.put_item(\n      table_name: '0pdd-events',\n      item: {\n        'repo' => @repo,\n        'vcs' => @vcs,\n        'time' => Time.now.to_i,\n        'tag' => tag,\n        'text' => \"#{text} /#{VERSION}\"\n      }\n    )\n  end\n\n  def get(tag)\n    @dynamo.query(\n      table_name: '0pdd-events',\n      index_name: 'tags',\n      select: 'ALL_ATTRIBUTES',\n      limit: 1,\n      expression_attribute_values: {\n        ':r' => @repo,\n        ':t' => tag\n      },\n      key_condition_expression: 'repo=:r and tag=:t'\n    ).items[0]\n  end\n\n  def exists(tag)\n    !@dynamo.query(\n      table_name: '0pdd-events',\n      index_name: 'tags',\n      select: 'ALL_ATTRIBUTES',\n      limit: 1,\n      expression_attribute_values: {\n        ':r' => @repo,\n        ':t' => tag\n      },\n      key_condition_expression: 'repo=:r and tag=:t'\n    ).items.empty?\n  end\n\n  def delete(time, tag)\n    @dynamo.delete_item(\n      table_name: '0pdd-events',\n      key: {\n        'repo' => @repo,\n        'time' => time\n      },\n      expression_attribute_values: {\n        ':t' => tag\n      },\n      condition_expression: 'tag=:t'\n    )\n  end\n\n  def list(since = Time.now.to_i)\n    @dynamo.query(\n      table_name: '0pdd-events',\n      select: 'ALL_ATTRIBUTES',\n      limit: 25,\n      scan_index_forward: false,\n      expression_attribute_names: {\n        '#time' => 'time'\n      },\n      expression_attribute_values: {\n        ':r' => @repo,\n        ':t' => since\n      },\n      key_condition_expression: 'repo=:r and #time<:t'\n    )\n  end\nend\n"
  },
  {
    "path": "objects/maybe_text.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Maybe text\n#\nclass MaybeText\n  def initialize(text_if_present, maybe, exclude_if: false)\n    @maybe = maybe\n    @text = text_if_present\n    @exclude_if = exclude_if\n  end\n\n  def to_s\n    if @maybe.nil? || @maybe.empty? || @maybe == @exclude_if\n      ''\n    else\n      @text\n    end\n  end\nend\n"
  },
  {
    "path": "objects/puzzles.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'json'\nrequire 'crack'\nrequire 'nokogiri'\nrequire_relative '../model/linear'\n\n#\n# Puzzles in XML/S3\n# @todo #532:60min Implement a decorator for optional model configuration load.\n#  Let's implement a class that decorates `Puzzles` and\n#  based on presence of `model: true` attribute in YAML config, decides\n#  whether the puzzles should be ranked or not.\n#  Don't forget to remove this puzzle.\n#\nclass Puzzles\n  def initialize(repo, storage)\n    @repo = repo\n    @storage = storage\n    t = repo.config && repo.config['threshold'].to_i\n    @threshold = t.positive? && t < 256 ? t : 256\n  end\n\n  # Find out which puzzles deservers to become new tickets and submit\n  # them to the repository (GitHub, for example). Also, find out which\n  # puzzles are no longer active and remove them from GitHub.\n  def deploy(tickets)\n    xml = join(@storage.load, @repo.xml)\n    xml = group(xml)\n    save(xml)\n    expose(xml, tickets)\n  end\n\n  private\n\n  # Save new XML into the storage, replacing the existing one.\n  def save(xml)\n    @storage.save(xml)\n  end\n\n  # Join existing XML with the snapshot just arrived from PDD\n  # toolkit output after the analysis of the code base. New <puzzle>\n  # elements are added as <extra> elements. They later inside the\n  # method join() will be placed to the right positions and will\n  # either replace existing ones of will become new puzzles.\n  def join(before, snapshot)\n    after = Nokogiri::XML(before.to_s)\n    target = after.xpath('/puzzles')[0]\n    snapshot.xpath('//puzzle').each do |p|\n      p.name = 'extra'\n      target.add_child(p)\n    end\n    after\n  end\n\n  # Merge <extra> elements with <puzzle> elements in the XML. Some\n  # extras will be simply deleted, while others will become new\n  # puzzles.\n  def group(xml)\n    Nokogiri::XSLT(File.read('assets/xsl/group.xsl')).transform(\n      Nokogiri::XSLT(File.read('assets/xsl/join.xsl')).transform(xml)\n    )\n  end\n\n  # Take some puzzles from the XML and either close their tickets in GitHub\n  # or create new tickets.\n  def expose(xml, tickets)\n    seen = []\n    Kernel.loop do\n      puzzles = xml.xpath(\n        [\n          '//puzzle[@alive=\"false\" and issue',\n          'and issue != \"unknown\" and not(issue/@closed)',\n          seen.map { |i| \"and id != '#{i}'\" }.join(' '),\n          ']'\n        ].join(' ')\n      )\n      break if puzzles.empty?\n      puzzle = puzzles[0]\n      puzzle.search('issue')[0]['closed'] = Time.now.iso8601 if tickets.close(puzzle)\n      save(xml)\n    end\n    seen = []\n    Kernel.loop do\n      puzzles = xml.xpath(\n        [\n          '//puzzle[@alive=\"true\" and (not(issue) or issue=\"unknown\")',\n          seen.map { |i| \"and id != '#{i}'\" }.join(' '),\n          ']'\n        ].join(' ')\n      )\n      break if puzzles.empty?\n      puzzle = puzzles[0]\n      id = puzzle.xpath('id')[0].text\n      seen << id\n      issue = tickets.submit(puzzle)\n      next if issue.nil?\n      puzzle.search('issue').remove\n      puzzle.add_child(\n        \"<issue href='#{issue[:href]}'>#{issue[:number]}</issue>\"\n      )\n      save(xml)\n    end\n  end\nend\n"
  },
  {
    "path": "objects/storage/cached_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# XML cached in a temporary file.\n#\nclass CachedStorage\n  def initialize(origin, file)\n    @origin = origin\n    @file = file\n  end\n\n  def load\n    if File.exist?(@file)\n      begin\n        content = File.read(@file)\n      rescue StandardError => e\n        raise \"Failed to read #{@file} due to #{e.cause.inspect}\"\n      end\n      xml = Nokogiri::XML(content)\n    else\n      xml = @origin.load\n      write(xml)\n    end\n    xml\n  end\n\n  def save(xml)\n    FileUtils.rm_rf(@file)\n    @origin.save(xml)\n    write(xml.to_s)\n  end\n\n  private\n\n  def write(xml)\n    FileUtils.mkdir_p(File.dirname(@file))\n    File.write(@file, xml)\n  end\nend\n"
  },
  {
    "path": "objects/storage/logged_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Storage that is logged.\n#\nclass LoggedStorage\n  def initialize(origin, log)\n    @origin = origin\n    @log = log\n  end\n\n  def load\n    @origin.load\n  end\n\n  def save(xml)\n    @origin.save(xml)\n    @log.put(\n      \"save-#{Time.now.to_i}\",\n      \"Saved XML, puzzles:#{xml.xpath('//puzzle[@alive=\"true\"]').size}/\\\n#{xml.xpath('//puzzle').size}, chars:#{xml.to_s.length}, \\\ndate:#{xml.xpath('/*/@date')[0].text}, \\\nversion:#{xml.xpath('/*/@version')[0].text}\"\n    )\n  end\nend\n"
  },
  {
    "path": "objects/storage/once_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Saves only once, if the content wasn't really changed.\n#\nclass OnceStorage\n  def initialize(origin)\n    @origin = origin\n  end\n\n  def load\n    @origin.load\n  end\n\n  def save(xml)\n    @origin.save(xml) if load.to_s != xml.to_s\n  end\nend\n"
  },
  {
    "path": "objects/storage/s3.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'aws-sdk-s3'\nrequire 'nokogiri'\nrequire_relative '../../version'\n\n#\n# S3 storage.\n#\nclass S3\n  def initialize(ocket, bucket, region, key, secret)\n    @object = Aws::S3::Resource.new(\n      region: region,\n      credentials: Aws::Credentials.new(key, secret)\n    ).bucket(bucket).object(ocket)\n  end\n\n  def load\n    Nokogiri::XML(\n      if @object.exists?\n        data = @object.get.body\n        puts \"S3 #{data.size} from #{@object.bucket_name}/#{@object.key}\"\n        data\n      else\n        puts \"Empty puzzles for #{@object.bucket_name}/#{@object.key}\"\n        '<puzzles xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\nxsi:noNamespaceSchemaLocation=\"https://www.0pdd.com/puzzles.xsd\"/>'\n      end\n    )\n  end\n\n  def save(xml)\n    data = xml.to_s\n    @object.put(body: data)\n    puts \"S3 #{data.size} to #{@object.bucket_name}/#{@object.key} \\\n(#{xml.xpath('//puzzle').size} puzzles)\"\n  end\nend\n"
  },
  {
    "path": "objects/storage/safe_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\n\n#\n# Safe, XSD validated, storage.\n#\nclass SafeStorage\n  def initialize(origin)\n    @origin = origin\n    @xsd = Nokogiri::XML::Schema(File.read('assets/xsd/puzzles.xsd'))\n  end\n\n  def load\n    @origin.load\n  end\n\n  def save(xml)\n    @origin.save(valid(xml))\n  end\n\n  private\n\n  def valid(xml)\n    errors = @xsd.validate(xml).each(&:message)\n    raise \"XML has #{errors.length} errors\\nw#{errors.join(\"\\n\")}\\n#{xml}\" unless errors.empty?\n    xml\n  end\nend\n"
  },
  {
    "path": "objects/storage/sync_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Thread-safe storage.\n#\nclass SyncStorage\n  def initialize(origin)\n    @origin = origin\n    @mutex = Mutex.new\n  end\n\n  def load\n    @mutex.synchronize { @origin.load }\n  end\n\n  def save(xml)\n    @mutex.synchronize { @origin.save(xml) }\n  end\nend\n"
  },
  {
    "path": "objects/storage/upgraded_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Storage that upgrades itself on load.\n#\nclass UpgradedStorage\n  def initialize(origin, version)\n    @origin = origin\n    @version = version\n  end\n\n  def load\n    xml = @origin.load\n    if xml.xpath('/*/@version')[0] != @version\n      %w[remove-broken-issues add-namespace].each do |xsl|\n        xml = Nokogiri::XSLT(\n          File.read(\"assets/upgrades/#{xsl}.xsl\")\n        ).transform(xml)\n      end\n      save(xml)\n    end\n    xml\n  end\n\n  def save(xml)\n    @origin.save(xml)\n  end\nend\n"
  },
  {
    "path": "objects/storage/versioned_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Storage that adds version to the XML when it gets saved.\n#\nclass VersionedStorage\n  def initialize(origin, version)\n    @origin = origin\n    @version = version\n  end\n\n  def load\n    xml = @origin.load\n    root = xml.xpath('/*')[0]\n    unless root['date']\n      root['date'] = '2016-12-08T12:00:49Z'\n      root['version'] = '0.0.0'\n    end\n    xml\n  end\n\n  def save(xml)\n    root = xml.xpath('/*')[0]\n    root['date'] = Time.now.iso8601\n    root['version'] = @version\n    @origin.save(xml)\n  end\nend\n"
  },
  {
    "path": "objects/templates/github_tickets_body.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\nThe puzzle `#{puzzle.xpath('id')[0].text}` |\nfrom ##{puzzle.xpath('ticket')[0].text} has to be resolved: |\n\\\n#{url}\n\\\nThe puzzle was created by #{puzzle.xpath('author')[0].text} on |\n#{Time.parse(puzzle.xpath('time')[0].text).strftime('%d-%b-%y')}. |\n\\\n#{MaybeText.new(\"Estimate: #{puzzle.xpath('estimate')[0].text} minutes, \", puzzle.xpath('estimate')[0].text, exclude_if: '0')} |\n#{MaybeText.new(\"role: #{puzzle.xpath('role')[0].text}.\", puzzle.xpath('role')[0].text, exclude_if: 'IMP')} |\n\\\nIf you have any technical questions, don't ask me, |\nsubmit new tickets instead. The task will be \\\"done\\\" when |\nthe problem is fixed and the text of the puzzle is |\n_removed_ from the source code. Here is more about |\n[PDD](http://www.yegor256.com/2009/03/04/pdd.html) and |\n[about me](http://www.yegor256.com/2017/04/05/pdd-in-action.html). |\n"
  },
  {
    "path": "objects/templates/gitlab_tickets_body.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\nThe puzzle `#{puzzle.xpath('id')[0].text}` |\nfrom ##{puzzle.xpath('ticket')[0].text} has to be resolved: |\n\\\n#{url}\n\\\nThe puzzle was created by #{puzzle.xpath('author')[0].text} on |\n#{Time.parse(puzzle.xpath('time')[0].text).strftime('%d-%b-%y')}. |\n\\\n#{MaybeText.new(\"Estimate: #{puzzle.xpath('estimate')[0].text} minutes, \", puzzle.xpath('estimate')[0].text, exclude_if: '0')} |\n#{MaybeText.new(\"role: #{puzzle.xpath('role')[0].text}.\", puzzle.xpath('role')[0].text, exclude_if: 'IMP')} |\n\\\nIf you have any technical questions, don't ask me, |\nsubmit new tickets instead. The task will be \\\"done\\\" when |\nthe problem is fixed and the text of the puzzle is |\n_removed_ from the source code. Here is more about |\n[PDD](http://www.yegor256.com/2009/03/04/pdd.html) and |\n[about me](http://www.yegor256.com/2017/04/05/pdd-in-action.html). |\n"
  },
  {
    "path": "objects/templates/jira_tickets_body.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\nThe puzzle `#{puzzle.xpath('id')[0].text}` |\nfrom ##{puzzle.xpath('ticket')[0].text} has to be resolved: |\n\\\n#{url}\n\\\nThe puzzle was created by #{puzzle.xpath('author')[0].text} on |\n#{Time.parse(puzzle.xpath('time')[0].text).strftime('%d-%b-%y')}. |\n\\\n#{MaybeText.new(\"Estimate: #{puzzle.xpath('estimate')[0].text} minutes, \", puzzle.xpath('estimate')[0].text, exclude_if: '0')} |\n#{MaybeText.new(\"role: #{puzzle.xpath('role')[0].text}.\", puzzle.xpath('role')[0].text, exclude_if: 'IMP')} |\n\\\nIf you have any technical questions, don't ask me, |\nsubmit new tickets instead. The task will be \\\"done\\\" when |\nthe problem is fixed and the text of the puzzle is |\n_removed_ from the source code. Here is more about |\n[PDD](http://www.yegor256.com/2009/03/04/pdd.html) and |\n[about me](http://www.yegor256.com/2017/04/05/pdd-in-action.html). |\n"
  },
  {
    "path": "objects/tickets/commit_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Tickets that post into commits.\n#\nclass CommitTickets\n  def initialize(vcs, tickets)\n    @vcs = vcs\n    @commit = vcs.repo.head_commit_hash\n    @tickets = tickets\n  end\n\n  def notify(issue, message)\n    @tickets.notify(issue, message)\n  end\n\n  def submit(puzzle)\n    done = @tickets.submit(puzzle)\n    return done if suppressed_repo?\n\n    @vcs.create_commit_comment(\n      @commit,\n      \"Puzzle `#{puzzle.xpath('id')[0].text}` discovered in \\\n  [`#{puzzle.xpath('file')[0].text}`](#{@vcs.file_link(puzzle.xpath('file')[0].text)}) \\\n  and submitted as ##{done[:number]}. Please, remember that the puzzle was not \\\n  necessarily added in this particular commit. Maybe it was added earlier, but \\\n  we discovered it only now.\"\n    )\n    done\n  end\n\n  def close(puzzle)\n    done = @tickets.close(puzzle)\n    if done && !opts.include?('on-lost-puzzle')\n      @vcs.create_commit_comment(\n        @commit,\n        \"Puzzle `#{puzzle.xpath('id')[0].text}` disappeared from \\\n[`#{puzzle.xpath('file')[0].text}`](#{@vcs.file_link(puzzle.xpath('file')[0].text)}), \\\nthat's why I closed ##{puzzle.xpath('issue')[0].text}. \\\nPlease, remember that the puzzle was not necessarily removed in this \\\nparticular commit. Maybe it happened earlier, but we discovered this fact \\\nonly now.\"\n      )\n    end\n    done\n  end\n\n  private\n\n  def opts\n    array = @vcs.repo.config.dig('alerts', 'suppress')\n    array.nil? || !array.is_a?(Array) ? [] : array\n  end\n\n  def suppressed_repo?\n    suppressed_options = %w[on-found-puzzle on-scope]\n    suppressed_options.any? { |item| opts.include?(item) }\n  end\nend\n"
  },
  {
    "path": "objects/tickets/emailed_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Tickets that email when submitted or closed.\n#\nclass EmailedTickets\n  def initialize(vcs, tickets)\n    @vcs = vcs\n    @tickets = tickets\n  end\n\n  def notify(issue, message)\n    @tickets.notify(issue, message)\n  end\n\n  def submit(puzzle)\n    done = @tickets.submit(puzzle)\n    issue_link = @vcs.issue_link(done[:number])\n    file_link = @vcs.file_link(puzzle.xpath('file')[0].text)\n    Mail.new do\n      from '0pdd <no-reply@0pdd.com>'\n      to 'admin@0pdd.com'\n      subject \"#{issue_link} opened\"\n      text_part do\n        content_type 'text/plain; charset=UTF-8'\n        body \"Hey,\\n\\n\\\nIssue #{done[:href]} opened.\\n\\n\\\nID: #{puzzle.xpath('id')[0].text}\\n\\\nFile: #{puzzle.xpath('file')[0].text}\\n\\\nLines: #{puzzle.xpath('lines')[0].text}\\n\\\nHere: #{file_link}\\\n##{puzzle.xpath('lines')[0].text.gsub(/(\\d+)/, 'L\\1')}\\n\\\nAuthor: #{puzzle.xpath('author')[0].text}\\n\\\nTime: #{puzzle.xpath('time')[0].text}\\n\\\nEstimate: #{puzzle.xpath('estimate')[0].text} minutes\\n\\\nRole: #{puzzle.xpath('role')[0].text}\\n\\n\\\nBody: #{puzzle.xpath('body')[0].text}\\n\\n\\\nThanks,\\n\\\n0pdd\"\n      end\n    end.deliver!\n    done\n  end\n\n  def close(puzzle)\n    done = @tickets.close(puzzle)\n    if done\n      issue_number = puzzle.xpath('issue')[0].text\n      issue_link = @vcs.issue_link(issue_number)\n      Mail.new do\n        from '0pdd <no-reply@0pdd.com>'\n        to 'admin@0pdd.com'\n        subject \"#{issue_link} closed\"\n        text_part do\n          content_type 'text/plain; charset=UTF-8'\n          body \"Hey,\\n\\n\\\nIssue #{issue_link} closed.\\n\\n\\\nThanks,\\n\\\n0pdd\"\n        end\n      end.deliver!\n    end\n    done\n  end\nend\n"
  },
  {
    "path": "objects/tickets/logged_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'cgi'\nrequire_relative '../truncated'\nrequire_relative '../user_error'\n\n#\n# Tickets that are logged.\n#\nclass LoggedTickets\n  def initialize(vcs, log, tickets)\n    @vcs = vcs\n    @log = log\n    @tickets = tickets\n  end\n\n  def notify(issue, message)\n    @tickets.notify(issue, message)\n  end\n\n  def submit(puzzle)\n    tag = \"#{puzzle.xpath('id')[0].text}/submit\"\n    if @log.exists(tag)\n      raise UserError, \"Tag \\\"#{tag}\\\" already exists, won't submit again. \\\nThis situation most probably means that \\\nthis puzzle was already seen in the code and \\\nyou're trying to create it again. We would recommend you to re-phrase \\\nthe text of the puzzle and push again. If this doesn't work, please let us know \\\nin GitHub: https://github.com/yegor256/0pdd/issues. More details here: \\\nhttps://www.0pdd.com/log-item?repo=#{CGI.escape(@vcs.repo.name)}&tag=#{CGI.escape(tag)}&vcs=#{@vcs.name.downcase} .\"\n    end\n    done = @tickets.submit(puzzle)\n    @log.put(\n      tag,\n      \"#{puzzle.xpath('id')[0].text} submitted in issue ##{done[:number]}: \\\n\\\"#{Truncated.new(puzzle.xpath('body')[0].text, 100)}\\\" \\\nat #{puzzle.xpath('file')[0].text}; #{puzzle.xpath('lines')[0].text}\"\n    )\n    done\n  end\n\n  def close(puzzle)\n    done = @tickets.close(puzzle)\n    if done\n      tag = \"#{puzzle.xpath('id')[0].text}/closed\"\n      if @log.exists(tag)\n        raise UserError, \"Tag \\\"#{tag}\\\" already exists, won't close again. \\\nThis is a rare and rather unusual bug. Please report it to us: \\\nhttps://github.com/yegor256/0pdd/issues. More details here: \\\nhttps://www.0pdd.com/log-item?repo=#{CGI.escape(@vcs.repo.name)}&tag=#{CGI.escape(tag)}&vcs=#{@vcs.name.downcase} .\"\n      end\n      @log.put(\n        tag,\n        \"#{puzzle.xpath('id')[0].text} closed in issue \\\n##{puzzle.xpath('issue')[0].text}\"\n      )\n    end\n    done\n  end\nend\n"
  },
  {
    "path": "objects/tickets/milestone_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Tickets that inherit milestones.\n#\nclass MilestoneTickets\n  def initialize(vcs, tickets)\n    @vcs = vcs\n    @tickets = tickets\n  end\n\n  def notify(issue, message)\n    @tickets.notify(issue, message)\n  end\n\n  def submit(puzzle)\n    submitted = @tickets.submit(puzzle)\n    config = @vcs.repo.config\n    if config['tickets']&.include?('inherit-milestone') &&\n       puzzle.xpath('ticket')[0].text =~ /[0-9]+/\n      num = puzzle.xpath('ticket')[0].text.to_i\n      parent = @vcs.issue(num)\n      unless parent.nil? || parent[:milestone].nil?\n        begin\n          @vcs.update_issue(\n            num,\n            milestone: parent[:milestone][:number]\n          )\n          unless config.dig('alerts', 'suppress')\n            &.include?('on-inherited-milestone')\n            @vcs.add_comment(\n              submitted[:number],\n              \"This puzzle inherited milestone \\\n`#{parent[:milestone][:title]}` from issue ##{num}.\"\n            )\n          end\n        rescue Octokit::Error, Gitlab::Error::Error, JIRA::Error::Error => e\n          @vcs.add_comment(\n            submitted[:number],\n            \"For some reason I wasn't able to set milestone \\\n`#{parent[:milestone][:title]}`, inherited from `#{num}`, \\\nto this issue. Please, \\\n[submit a ticket](https://github.com/yegor256/0pdd/issues/new) \\\nto us with the text you see below:\\\n\\n\\n```#{e.class.name}\\n#{e.message}\\n#{e.backtrace.join(\"\\n\")}\\n```\"\n          )\n        end\n      end\n    end\n    submitted\n  end\n\n  def close(puzzle)\n    @tickets.close(puzzle)\n  end\nend\n"
  },
  {
    "path": "objects/tickets/sentry_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'mail'\nrequire 'sentry-ruby'\nrequire_relative '../user_error'\nrequire_relative '../truncated'\n\n#\n# Tickets that report to Sentry.\n#\nclass SentryTickets\n  def initialize(tickets)\n    @tickets = tickets\n  end\n\n  def notify(issue, message)\n    @tickets.notify(issue, message)\n  rescue UserError => e\n    puts e.message\n  rescue Exception => e\n    Sentry.capture_exception(e)\n    email(e)\n    raise e\n  end\n\n  def submit(puzzle)\n    @tickets.submit(puzzle)\n  rescue UserError => e\n    puts e.message\n    nil\n  rescue Exception => e\n    Sentry.capture_exception(e)\n    email(e)\n    raise e\n  end\n\n  def close(puzzle)\n    @tickets.close(puzzle)\n  rescue UserError => e\n    puts e.message\n    true\n  rescue Exception => e\n    Sentry.capture_exception(e)\n    email(e)\n    raise e\n  end\n\n  private\n\n  def email(e)\n    mail = Mail.new do\n      from '0pdd <no-reply@0pdd.com>'\n      to 'admin@0pdd.com'\n      subject Truncated.new(e.message).to_s\n      text_part do\n        content_type 'text/plain; charset=UTF-8'\n        body \"Hi,\\n\\n\\\n#{e.message}\\n\\n\n#{e.backtrace.join(\"\\n\")}\\n\\n\nThanks,\\n\\\n0pdd\"\n      end\n      html_part do\n        content_type 'text/html; charset=UTF-8'\n        body \"<html><body><p>Hi,</p>\n        <pre>#{e.message}\\n\\n#{e.backtrace.join(\"\\n\")}</pre>\n        </body></html>\"\n      end\n    end\n    mail.deliver!\n  end\nend\n"
  },
  {
    "path": "objects/tickets/tagged_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Tagged tickets.\n#\nclass TaggedTickets\n  def initialize(vcs, tickets)\n    @vcs = vcs\n    @tickets = tickets\n  end\n\n  def notify(issue, message)\n    @tickets.notify(issue, message)\n  end\n\n  def submit(puzzle)\n    issue = @tickets.submit(puzzle)\n    issue_id = issue[:number]\n    yaml = @vcs.repo.config\n    if yaml['tags'].is_a?(Array)\n      tags = yaml['tags'].map { |x| x.strip.downcase }\n      labels = @vcs.labels\n        .map { |json| json[:name] }\n        .map { |x| x.strip.downcase }\n      needed = tags - labels\n      begin\n        needed.each { |t| @vcs.add_label(t, 'F74219') }\n        @vcs.add_labels_to_an_issue(issue_id, tags)\n      rescue Octokit::Error, Gitlab::Error::Error, JIRA::Error::Error => e\n        @vcs.add_comment(\n          issue_id,\n          \"I can't create #{@vcs.name} labels `#{needed.join('`, `')}`. \\\nMost likely I don't have necessary permissions to `#{@vcs.repo.name}` repository. \\\nPlease, make sure @0pdd user is in the \\\n[list of collaborators](#{@vcs.collaborators_link}):\\\n\\n\\n```#{e.class.name}\\n#{e.message}\\n#{e.backtrace.join(\"\\n\")}\\n```\"\n        )\n      rescue Octokit::NotFound, Gitlab::Error::NotFound, JIRA::Error::NotFound => e\n        @vcs.add_comment(\n          issue_id,\n          \"For some reason I wasn't able to add #{@vcs.name} labels \\\n`#{needed.join('`, `')}` to this issue \\\n(required=`#{tags.join('`, `')}`; existing=`#{labels.join('`, `')}`). \\\nPlease, [submit a ticket](https://github.com/yegor256/0pdd/issues/new) \\\nto us with the text you see below:\\\n\\n\\n```#{e.class.name}\\n#{e.message}\\n#{e.backtrace.join(\"\\n\")}\\n```\"\n        )\n      end\n    end\n    issue\n  end\n\n  def close(puzzle)\n    @tickets.close(puzzle)\n  end\nend\n"
  },
  {
    "path": "objects/tickets/tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'haml'\nrequire_relative '../truncated'\nrequire_relative '../maybe_text'\n\n#\n# One ticket.\n#\nclass Tickets\n  def initialize(vcs)\n    @vcs = vcs\n  end\n\n  def notify(issue, message)\n    @vcs.add_comment(\n      issue,\n      \"@#{@vcs.issue(issue)[:author][:username]} #{message}\"\n    )\n  rescue Octokit::NotFound, Gitlab::NotFound, JIRA::NotFound => e\n    puts \"The issue most probably is not found, can't comment: #{e.message}\"\n  end\n\n  def submit(puzzle)\n    data = { title: title(puzzle), description: body(puzzle) }\n    issue = @vcs.create_issue(data)\n    unless users.empty?\n      @vcs.add_comment(\n        issue[:number],\n        (users + ['please pay attention to this new issue.']).join(' ')\n      )\n    end\n    { number: issue[:number], href: issue[:html_url] }\n  end\n\n  def close(puzzle)\n    issue = puzzle.xpath('issue')[0].text\n    return true if @vcs.issue(issue)[:state] == 'closed'\n    @vcs.close_issue(issue)\n    @vcs.add_comment(\n      issue,\n      [\n        \"The puzzle `#{puzzle.xpath('id')[0].text}` has disappeared\",\n        \" from the source code, that's why I closed this issue.\",\n        (users.empty? ? '' : \" //cc #{users.join(' ')}\")\n      ].join\n    )\n    true\n  end\n\n  private\n\n  def users\n    yaml = @vcs.repo.config\n    if !yaml.nil? && yaml['alerts'] && yaml['alerts'][@vcs.name.downcase]\n      yaml['alerts'][@vcs.name.downcase]\n        .map { |x| x.strip.downcase }\n        .map { |n| n.gsub(/[^0-9a-zA-Z-]+/, '') }\n        .map { |n| n[0..64] }\n        .map { |n| \"@#{n}\" }\n    else\n      []\n    end\n  end\n\n  def title(puzzle)\n    yaml = @vcs.repo.config\n    format = []\n    format += yaml['format'].map { |x| x.strip.downcase } if !yaml.nil? && yaml['format'].is_a?(Array)\n    len = format.find { |i| i =~ /title-length=\\d+/ }\n    Truncated.new(\n      if format.include?('short-title')\n        puzzle.xpath('body')[0].text\n      else\n        subject = File.basename(puzzle.xpath('file')[0].text)\n        start, stop = puzzle.xpath('lines')[0].text.split('-')\n        [\n          subject,\n          ':',\n          (start == stop ? start : \"#{start}-#{stop}\"),\n          \": #{puzzle.xpath('body')[0].text}\"\n        ].join\n      end,\n      [[len ? len.gsub(/^title-length=/, '').to_i : 60, 30].max, 255].min\n    ).to_s\n  end\n\n  def body(puzzle)\n    file = puzzle.xpath('file')[0].text\n    start, stop = puzzle.xpath('lines')[0].text.split('-')\n    sha = @vcs.repo.head_commit_hash || vcs.repo.master\n    url = @vcs.puzzle_link_for_commit(sha, file, start, stop)\n    template = File.read(\n      File.join(File.dirname(__FILE__), \"../templates/#{@vcs.name.downcase}_tickets_body.haml\")\n    )\n    Haml::Engine.new(template).render(\n      Object.new, url: url, puzzle: puzzle\n    )\n  end\nend\n"
  },
  {
    "path": "objects/truncated.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# Truncated text.\n#\nclass Truncated\n  def initialize(text, max = 40, tail = '...')\n    @text = text\n    @max = max\n    @tail = tail\n  end\n\n  def to_s\n    clean = @text.gsub(/\\s+/, ' ').strip\n    if @max < clean.length\n      limit = @max - @tail.length\n      stop = clean.rindex(' ', limit) || 0\n      \"#{clean[0...stop]}#{@tail}\"\n    else\n      clean\n    end\n  end\nend\n"
  },
  {
    "path": "objects/user_error.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\n#\n# User Error\n#\nclass UserError < StandardError\nend\n"
  },
  {
    "path": "objects/vcs/github.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'octokit'\nrequire_relative '../git_repo'\n\n#\n# Github VCS\n#\nclass GithubRepo\n  attr_reader :repo, :name\n\n  def initialize(client, json, config = {})\n    @name = 'github'\n    @client = client\n    @config = config\n    @json = json\n    @repo = git_repo(json, config)\n  end\n\n  # Check whether this repository exists in GitHub and we have\n  # access to it. Well, the actual access permissions are not checked\n  # here, but we only try to read properties of the repo. If such a HTTP\n  # request fails, the method returns FALSE.\n  def exists?\n    @client.repository(@repo.name)\n    true\n  rescue Octokit::NotFound => e\n    puts \"Repository #{@repo.name} is not available: #{e.message}\"\n    false\n  end\n\n  # Read information about one issue in GitHub and return it\n  # as a map.\n  def issue(issue_id)\n    hash = @client.issue(@repo.name, issue_id)\n    id = hash[:user][:id] if hash[:user]\n    username = hash[:user][:login] if hash[:user]\n    {\n      state: hash[:state],\n      author: {\n        id: id,\n        username: username\n      },\n      milestone: hash[:milestone]\n    }\n  end\n\n  # @todo #312:30min Currently, if 0pdd fails to close an issue it causes all other downstream execution to be skipped\n  #  therefore leaving the job in a non deterministic state. Catch and track the error here to\n  #  prevent this from happening. Also applies to `add_comment(...)`\n  def close_issue(issue_id)\n    @client.close_issue(@repo.name, issue_id)\n  end\n\n  def create_issue(data)\n    fields = %i[title description]\n    options = data.reject { |k| fields.include? k }\n    @client.create_issue(\n      @repo.name,\n      data[:title],\n      data[:description],\n      options\n    )\n  end\n\n  def update_issue(issue_id, data)\n    @client.update_issue(@repo.name, issue_id, data)\n  end\n\n  def labels\n    @client.labels(@repo.name)\n  end\n\n  def add_label(label, color)\n    @client.add_label(@repo.name, label, color)\n  end\n\n  def add_labels_to_an_issue(issue_id, labels)\n    @client.add_labels_to_an_issue(@repo.name, issue_id, labels)\n  end\n\n  def add_comment(issue_id, comment)\n    @client.add_comment(@repo.name, issue_id, comment)\n  end\n\n  def create_commit_comment(sha, comment)\n    @client.create_commit_comment(@repo.name, sha, comment)\n  end\n\n  def list_commits\n    @client.commits(@repo.name)\n  end\n\n  def user(username)\n    @client.user(username)\n  end\n\n  def star\n    @client.star(@repo.name)\n  end\n\n  def repository_link\n    \"https://github.com/#{@repo.name}\"\n  end\n\n  def collaborators_link\n    \"https://github.com/#{@repo.name}/settings/collaboration\"\n  end\n\n  def file_link(file)\n    \"https://github.com/#{@repo.name}/blob/#{@repo.master}/#{file})\"\n  end\n\n  def puzzle_link_for_commit(sha, file, start, stop)\n    \"https://github.com/#{@repo.name}/blob/#{sha}/#{file}#L#{start}-L#{stop}\"\n  end\n\n  def issue_link(issue_id)\n    \"https://github.com/#{@repo.name}/issues/#{issue_id}\"\n  end\n\n  private\n\n  def git_repo(json, config)\n    uri = json['repository']['ssh_url'] || json['repository']['url']\n    target = json['ref']\n    name = json['repository']['full_name']\n    default_branch = json['repository']['master_branch']\n    head_commit_hash = json['head_commit'] ? json['head_commit']['id'] : ''\n    GitRepo.new(\n      uri: uri,\n      name: name,\n      id_rsa: config['id_rsa'],\n      target: target,\n      master: default_branch,\n      head_commit_hash: head_commit_hash\n    )\n  end\nend\n"
  },
  {
    "path": "objects/vcs/gitlab.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'gitlab'\nrequire_relative '../git_repo'\nrequire_relative '../clients/gitlab'\n\n#\n# Gitlab repo\n# API: https://github.com/NARKOZ/gitlab\n#\nclass GitlabRepo\n  attr_reader :repo, :name\n\n  def initialize(client, json, config = {})\n    @name = 'github'\n    @client = client\n    @config = config\n    @json = json\n    @repo = git_repo(json, config)\n  end\n\n  def issue(issue_id)\n    hash = JSON.parse(\n      @client.issue(@repo.name, issue_id).to_hash.to_json,\n      symbolize_names: true\n    )\n    number, title = hash[:milestone].values_at(:id, :title) if hash[:milestone]\n    {\n      state: hash[:state],\n      author: hash[:author],\n      milestone: {\n        number: number,\n        title: title\n      }\n    }\n  rescue Gitlab::Error::NotFound => e\n    raise \"The issue most probably is not found, can' comment: #{e.message}\"\n  end\n\n  def close_issue(issue_id)\n    @client.close_issue(@repo.name, issue_id)\n  rescue Gitlab::Error::NotFound => e\n    raise \"The issue most probably is not found, can't close: #{e.message}\"\n  end\n\n  def create_issue(data)\n    options = data.reject { |k| k == :title }\n    hash = JSON.parse(\n      @client.create_issue(@repo.name, data[:title], options).to_hash.to_json,\n      symbolize_names: true\n    )\n    { number: hash[:iid], html_url: hash[:web_url] }\n  end\n\n  def update_issue(issue_id, data)\n    @client.edit_issue(@repo.name, issue_id, data)\n  end\n\n  def labels\n    result = []\n    @client.labels(@repo.name).each_page do |page|\n      page.each do |label|\n        result << JSON.parse(\n          label.to_hash.to_json,\n          symbolize_names: true\n        )\n      end\n    end\n    result\n  end\n\n  def add_label(label, color)\n    @client.add_label(@repo.name, label, color)\n  end\n\n  def add_labels_to_an_issue(issue_id, labels)\n    options = { labels: labels }\n    @client.edit_issue(@repo.name, issue_id, options)\n  end\n\n  def add_comment(issue_id, comment)\n    @client.create_issue_note(@repo.name, issue_id, comment)\n  rescue Gitlab::Error::NotFound => e\n    raise \"The issue most probably is not found, can't comment: #{e.message}\"\n  end\n\n  def create_commit_comment(sha, comment)\n    hash = JSON.parse(\n      @client.create_commit_comment(@repo.name, sha, comment).to_hash.to_json,\n      symbolize_names: true\n    )\n    hash[:html_url] = \"https://gitlab.com/#{@repo.name}/commit/#{sha}\"\n    hash\n  end\n\n  def list_commits\n    commits = []\n    @client.commits(@repo.name).each_page do |page|\n      page.each do |commit|\n        commits << { sha: commit.id, html_url: commit.web_url }\n      end\n    end\n    commits\n  end\n\n  def user(username)\n    hash = JSON.parse(\n      @client.user(username).to_hash.to_json,\n      symbolize_names: true\n    )\n    hash[:email] = hash[:public_email]\n    hash\n  end\n\n  def star\n    @client.star_project(@repo.name)\n  end\n\n  def exists?\n    hash = JSON.parse(\n      @client.project(@repo.name).to_hash.to_json,\n      symbolize_names: true\n    )\n    hash[:private] = hash[:visibility] == 'private'\n    true\n  rescue Gitlab::Error::NotFound => e\n    puts \"Repository #{@repo.name} is not available: #{e.message}\"\n    false\n  rescue Gitlab::Error::Forbidden => e\n    puts \"Repository #{@repo.name} is not accessible: #{e.message}\"\n    false\n  end\n\n  def repository_link\n    \"https://gitlab.com/#{@repo.name}\"\n  end\n\n  def collaborators_link\n    \"https://gitlab.com/#{@repo.name}/project_members\"\n  end\n\n  def file_link(file)\n    \"https://gitlab.com/#{@repo.name}/blob/#{@repo.master}/#{file})\"\n  end\n\n  def puzzle_link_for_commit(sha, file, start, stop)\n    \"https://gitlab.com/#{@repo.name}/blob/#{sha}/#{file}#L#{start}-L#{stop}\"\n  end\n\n  def issue_link(issue_id)\n    \"https://gitlab.com/#{@repo.name}/issues/#{issue_id}\"\n  end\n\n  private\n\n  def git_repo(json, config)\n    uri = json['project']['url']\n    name = json['project']['path_with_namespace']\n    target = json['ref']\n    default_branch = json['project']['default_branch']\n    head_commit_hash = json['checkout_sha']\n    GitRepo.new(\n      uri: uri,\n      name: name,\n      target: target,\n      id_rsa: config['id_rsa'],\n      master: default_branch,\n      head_commit_hash: head_commit_hash\n    )\n  end\nend\n"
  },
  {
    "path": "objects/vcs/jira.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'jira-ruby'\nrequire_relative '../git_repo'\n\n#\n# Jira VCS\n#\nclass JiraRepo\n  attr_reader :repo, :name\n\n  def initialize(client, json, config = {})\n    @name = 'JIRA'\n    @client = client\n    @config = config\n    @json = json\n    @repo = git_repo(json, config)\n  end\n\n  def issue(issue_id)\n    @client.Issue.find(issue_id)\n  end\n\n  def close_issue(issue_id)\n    issue = @client.Issue.find(issue_id)\n    issue.save(\n      'fields' => {\n        'summary' => data[:description],\n        'project' => { 'id' => data[:repo] },\n        'issuetype' => { 'id' => '3' },\n        'status' => 'closed'\n      }\n    )\n    issue.fetch\n  end\n\n  def create_issue(data)\n    issue = @client.Issue.build\n    issue.save(\n      'fields' => {\n        'summary' => data[:description],\n        'project' => { 'id' => data[:repo] },\n        'issuetype' => { 'id' => '3' }\n      }\n    )\n    issue.fetch\n  end\n\n  def update_issue(issue_id, data)\n    issue = @client.Issue.find(issue_id)\n    issue.save(\n      'fields' => {\n        'summary' => data[:description],\n        'project' => { 'id' => data[:repo] },\n        'issuetype' => { 'id' => '3' }\n      }\n    )\n    issue.fetch\n  end\n\n  def exists?\n    @client.Project.find(@repo.name)\n    true\n  rescue JIRA::NotFound => e\n    puts \"Repository #{@repo.name} is not available: #{e.message}\"\n    false\n  end\n\n  def repository_link\n    \"https://your-domain.atlassian.net/rest/api/3/project#{@repo.name}\"\n  end\n\n  private\n\n  def git_repo(json, config)\n    uri = json['repository']['ssh_url'] || json['repository']['url']\n    name = json['repository']['full_name']\n    default_branch = json['repository']['master_branch']\n    head_commit_hash = json['head_commit']['id']\n    GitRepo.new(\n      uri: uri,\n      name: name,\n      id_rsa: config['id_rsa'],\n      master: default_branch,\n      head_commit_hash: head_commit_hash\n    )\n  end\nend\n"
  },
  {
    "path": "renovate.json",
    "content": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"extends\": [\n    \"config:base\"\n  ]\n}\n"
  },
  {
    "path": "test/fake_github.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nclass FakeGithub\n  attr_reader :name, :repo\n\n  def initialize(options = {})\n    @name = 'GITHUB'\n    @memberships = options[:memberships] || [\n      {\n        'state' => 'pending',\n        'organization' => {\n          'login' => 'github'\n        }\n      }, {\n        'state' => 'pending',\n        'organization' => {\n          'login' => 'zerocracy'\n        }\n      }\n    ]\n    @invitations = options[:invitations] || [\n      {\n        'id' => 1001,\n        'repository' => {\n          'name' => 'yegor256/0pdd'\n        }\n      }, {\n        'id' => 1023,\n        'repository' => {\n          'name' => 'yegor256/sixnines'\n        }\n      }\n    ]\n    @repositories = options[:repositories] || []\n    @repo = options[:repo]\n  end\n\n  def rate_limit\n    limit = Object.new\n\n    def limit.remaining\n      4096\n    end\n    limit\n  end\n\n  def update_organization_membership(org, options = {})\n    return unless options['state']\n    @memberships.find do |m|\n      m['organization']['login'] == org\n    end['state'] = options['state']\n  end\n\n  def organization_memberships(options = {})\n    if options['state']\n      @memberships.find_all { |m| m['state'] == options['state'] }\n    else\n      @memberships\n    end\n  end\n\n  def user_repository_invitations(_options = {})\n    @invitations\n  end\n\n  def accept_repository_invitation(id, _options = {})\n    invitation = @invitations.find { |i| i['id'] == id }\n    return false if invitation.nil?\n    @repositories.push(invitation['repository']['name'])\n    true\n  end\n\n  def repositories(user = nil, _options = {})\n    @repositories unless user\n  end\n\n  def issue(_)\n    {\n      state: 'open',\n      author: {\n        id: '1',\n        username: 'yegor256'\n      },\n      milestone: {\n        number: 1,\n        title: 'v0.1'\n      }\n    }\n  end\n\n  def close_issue(_); end\n\n  def create_issue(_)\n    {\n      number: 1,\n      html_url: 'url'\n    }\n  end\n\n  def update_issue(_, _); end\n\n  def labels\n    [\n      {\n        id: ``,\n        name: 'Dev',\n        color: '#ff00ff'\n      }\n    ]\n  end\n\n  def add_label(_, _); end\n\n  def add_labels_to_an_issue(_, _); end\n\n  def add_comment(_, _); end\n\n  def create_commit_comment(_, _, _)\n    {\n      html_url: 'url'\n    }\n  end\n\n  def list_commits\n    [\n      {\n        sha: '123456',\n        html_url: 'url'\n      }\n    ]\n  end\n\n  def user(_)\n    {\n      name: 'foobar',\n      email: 'foobar@example.com'\n    }\n  end\n\n  def star; end\n\n  def repository(_ = nil)\n    {\n      private: false\n    }\n  end\n\n  def repository_link\n    \"https://github.com/#{@repo.name}\"\n  end\n\n  def collaborators_link\n    \"https://github.com/#{@repo.name}/settings/collaboration\"\n  end\n\n  def file_link(file)\n    \"https://github.com/#{@repo.name}/blob/#{@repo.master}/#{file})\"\n  end\n\n  def puzzle_link_for_commit(sha, file, start, stop)\n    \"https://github.com/#{@repo.name}/blob/#{sha}/#{file}#L#{start}-L#{stop}\"\n  end\n\n  def issue_link(issue_id)\n    \"https://github.com/#{@repo.name}/issues/#{issue_id}\"\n  end\n\n  private\n\n  def git_repo\n    # Output:\n    # repo -> GitRepo\n    raise NotImplementedError, 'You must implement this method'\n  end\nend\n"
  },
  {
    "path": "test/fake_gitlab.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nclass FakeGitlab\n  attr_reader :name, :repo\n\n  def initialize(options = {})\n    @name = 'GITLAB'\n    @repositories = options[:repositories] || []\n    @projects = options[:projects] || []\n    @repo = options[:repo]\n  end\n\n  def repositories(user = nil, _options = {})\n    @repositories unless user\n  end\n\n  def issue(_)\n    {\n      state: 'open',\n      author: {\n        id: '1',\n        username: 'yegor256'\n      },\n      milestone: {\n        number: 1,\n        title: 'v0.1'\n      }\n    }\n  end\n\n  def close_issue(_); end\n\n  def create_issue(_)\n    {\n      number: 1,\n      html_url: 'url'\n    }\n  end\n\n  def update_issue(_, _); end\n\n  def labels\n    [\n      {\n        id: ``,\n        name: 'Dev',\n        color: '#ff00ff'\n      }\n    ]\n  end\n\n  def add_label(_, _); end\n\n  def add_labels_to_an_issue(_, _); end\n\n  def add_comment(_, _); end\n\n  def create_commit_comment(_, _)\n    {\n      html_url: 'url'\n    }\n  end\n\n  def list_commits\n    [\n      {\n        sha: '123456',\n        html_url: 'url'\n      }\n    ]\n  end\n\n  def user(_)\n    {\n      name: 'foobar',\n      email: 'foobar@example.com'\n    }\n  end\n\n  def star; end\n\n  def repository(_ = nil)\n    {\n      private: false\n    }\n  end\n\n  def project(_ = nil)\n    {\n      private: false\n    }\n  end\n\n  def repository_link\n    \"https://gitlab.com/#{@repo.name}\"\n  end\n\n  def collaborators_link\n    \"https://gitlab.com/#{@repo.name}/project_members\"\n  end\n\n  def file_link(file)\n    \"https://gitlab.com/#{@repo.name}/blob/#{@repo.master}/#{file})\"\n  end\n\n  def puzzle_link_for_commit(sha, file, start, stop)\n    \"https://gitlab.com/#{@repo.name}/blob/#{sha}/#{file}#L#{start}-L#{stop}\"\n  end\n\n  def issue_link(issue_id)\n    \"https://gitlab.com/#{@repo.name}/issues/#{issue_id}\"\n  end\n\n  private\n\n  def git_repo\n    # Output:\n    # repo -> GitRepo\n    raise NotImplementedError, 'You must implement this method'\n  end\nend\n"
  },
  {
    "path": "test/fake_log.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nclass FakeLog\n  attr_reader :tag, :title\n\n  def exists(_)\n    false\n  end\n\n  def put(tag, text)\n    @title = text\n    @tag = tag\n  end\n\n  def get(_tag); end\n\n  def delete(_time, _tag); end\n\n  def list(_since = Time.now.to_i)\n    []\n  end\nend\n"
  },
  {
    "path": "test/fake_repo.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire 'tempfile'\n\nclass FakeRepo\n  attr_reader :name, :config\n\n  def initialize(options = {})\n    @name = options[:name] || 'GITHUB'\n    @config = options[:config] || {}\n  end\n\n  def lock\n    Tempfile.new('0pdd-lock')\n  end\n\n  def xml\n    Nokogiri::XML('<puzzles date=\"2016-12-10T16:26:36Z\"/>')\n  end\n\n  def push\n    # nothing here\n  end\nend\n"
  },
  {
    "path": "test/fake_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire 'tempfile'\n\nclass FakeStorage\n  def initialize(\n    dir = Dir.mktmpdir,\n    xml = '<puzzles date=\"2016-12-10T16:26:36Z\" version=\"0.1\"/>'\n  )\n    @file = File.join(dir, 'storage.xml')\n    save(xml)\n  end\n\n  def load\n    Nokogiri.XML(File.read(@file))\n  end\n\n  def save(xml)\n    File.write(@file, xml.to_s)\n  end\nend\n"
  },
  {
    "path": "test/fake_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nclass FakeTickets\n  attr_reader :submitted, :closed\n\n  def initialize\n    @submitted = []\n    @closed = []\n  end\n\n  def submit(puzzle)\n    @submitted << puzzle.xpath('id')[0].text\n    { number: '123', href: 'http://0pdd.com' }\n  end\n\n  def close(puzzle)\n    @closed << puzzle.xpath('id')[0].text\n    true\n  end\nend\n"
  },
  {
    "path": "test/test_0pdd.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'rack/test'\nrequire_relative 'test__helper'\nrequire_relative '../0pdd'\n\nclass AppTest < Minitest::Test\n  include Rack::Test::Methods\n\n  def app\n    Sinatra::Application\n  end\n\n  def test_renders_version\n    get('/version')\n    assert_predicate(last_response, :ok?)\n  end\n\n  def test_robots_txt\n    get('/robots.txt')\n    assert_predicate(last_response, :ok?)\n  end\n\n  def test_it_renders_home_page\n    get('/')\n    assert_predicate(last_response, :ok?)\n    assert_includes(last_response.body, '0pdd')\n  end\n\n  def test_renders_some_pages\n    [\n      '/',\n      '/robots.txt',\n      '/version',\n      '/puzzles.xsd',\n      '/logout',\n      '/css/main.css'\n    ].each do |page|\n      get(page)\n      assert_operator(last_response.status, :<, 400, \"Failed to render #{page}\")\n    end\n  end\n\n  def test_it_renders_puzzles_xsd\n    get('/puzzles.xsd')\n    assert_predicate(last_response, :ok?)\n    assert_includes(last_response.body, '<xs:schema')\n  end\n\n  def test_renders_log_page\n    repo = 'yegor256/0pdd'\n    log = Log.new(Dynamo.new.aws, repo)\n    log.put('some-tag', 'some text here')\n    get(\"/log?name=#{repo}\")\n    assert_predicate(last_response, :ok?, last_response.body)\n    assert_includes(last_response.body, repo, last_response.body)\n    assert_includes(last_response.body, 'some text', last_response.body)\n  end\n\n  def test_renders_log_item\n    repo = 'yegor256/0pdd'\n    log = Log.new(Dynamo.new.aws, repo)\n    tag = 'some-tag'\n    log.put(tag, 'some text here')\n    get(\"/log-item?repo=#{repo}&tag=#{tag}\")\n    assert_predicate(last_response, :ok?, last_response.body)\n    assert_includes(last_response.body, repo, last_response.body)\n    assert_includes(last_response.body, 'some text', last_response.body)\n  end\n\n  def test_renders_page_not_found\n    get('/the-url-that-is-absent')\n    assert_equal(404, last_response.status)\n  end\n\n  def test_it_understands_push_from_github\n    headers = {\n      'CONTENT_TYPE' => 'application/json',\n      'HTTP_USER_AGENT' => 'GitHub-Hookshot',\n      'HTTP_X_GITHUB_EVENT' => 'push'\n    }\n    post(\n      '/hook/github',\n      ['{\"head_commit\":{\"id\":\"-\"},',\n       '\"repository\":{\"url\":\"localhost\",',\n       '\"full_name\":\"yegor256-one/com.github.0pdd-test\"},',\n       '\"ref\":\"refs/heads/master\"}'].join,\n      headers\n    )\n    assert_predicate(last_response, :ok?)\n    assert_includes(last_response.body, 'Thanks')\n  end\n\n  def test_it_ignores_push_from_github_to_not_master\n    headers = {\n      'CONTENT_TYPE' => 'application/json',\n      'HTTP_USER_AGENT' => 'GitHub-Hookshot',\n      'HTTP_X_GITHUB_EVENT' => 'push'\n    }\n    post(\n      '/hook/github',\n      ['{\"head_commit\":{\"id\":\"-\"},',\n       '\"repository\":{\"url\":\"localhost\",',\n       '\"full_name\":\"yegor256-one/com.github.0pdd-test\"},',\n       '\"ref\":\"refs/heads/main\"}'].join,\n      headers\n    )\n    assert_predicate(last_response, :ok?)\n    assert_includes(last_response.body, 'Thanks')\n    assert_includes(last_response.body, 'nothing is done')\n  end\n\n  def test_it_accepts_push_from_github_to_not_default_master\n    headers = {\n      'CONTENT_TYPE' => 'application/json',\n      'HTTP_USER_AGENT' => 'GitHub-Hookshot',\n      'HTTP_X_GITHUB_EVENT' => 'push'\n    }\n    post(\n      '/hook/github',\n      ['{\"head_commit\":{\"id\":\"-\"},',\n       '\"repository\":{\"url\":\"localhost\",',\n       '\"master_branch\": \"main\",',\n       '\"full_name\":\"yegor256-one/com.github.0pdd-test\"},',\n       '\"ref\":\"refs/heads/main\"}'].join,\n      headers\n    )\n    assert_predicate(last_response, :ok?)\n    assert_includes(last_response.body, 'Thanks')\n    refute_includes(last_response.body, 'nothing is done')\n  end\n\n  def test_it_ignore_push_from_github_to_not_default_master\n    headers = {\n      'CONTENT_TYPE' => 'application/json',\n      'HTTP_USER_AGENT' => 'GitHub-Hookshot',\n      'HTTP_X_GITHUB_EVENT' => 'push'\n    }\n    post(\n      '/hook/github',\n      ['{\"head_commit\":{\"id\":\"-\"},',\n       '\"repository\":{\"url\":\"localhost\",',\n       '\"master_branch\": \"main\",',\n       '\"full_name\":\"yegor256-one/com.github.0pdd-test\"},',\n       '\"ref\":\"refs/heads/master\"}'].join,\n      headers\n    )\n    assert_predicate(last_response, :ok?)\n    assert_includes(last_response.body, 'Thanks')\n    assert_includes(last_response.body, 'nothing is done')\n  end\n\n  def test_it_understands_push_from_gitlab\n    headers = {\n      'CONTENT_TYPE' => 'application/json',\n      'HTTP_USER_AGENT' => 'GitLab 16.6',\n      'HTTP_X_GITLAB_EVENT' => 'Push Hook'\n    }\n    post(\n      '/hook/gitlab',\n      ['{\"checkout_sha\": \"da1560886d4\",',\n       '\"project\":{\"url\":\"localhost\",',\n       '\"path_with_namespace\":\"yegor256-one/com.github.0pdd-test\"},',\n       '\"ref\":\"refs/heads/master\"}'].join,\n      headers\n    )\n    assert_predicate(last_response, :ok?)\n    assert_includes(last_response.body, 'Thanks')\n  end\n\n  def test_it_ignores_push_from_gitlab_to_not_master\n    headers = {\n      'CONTENT_TYPE' => 'application/json',\n      'HTTP_USER_AGENT' => 'GitLab 16.6',\n      'HTTP_X_GITLAB_EVENT' => 'Push Hook'\n    }\n    post(\n      '/hook/gitlab',\n      ['{\"checkout_sha\": \"da1560886d4\",',\n       '\"project\":{\"url\":\"localhost\",',\n       '\"path_with_namespace\":\"yegor256-one/com.github.0pdd-test\"},',\n       '\"ref\":\"refs/heads/main\"}'].join,\n      headers\n    )\n    assert_predicate(last_response, :ok?)\n    assert_includes(last_response.body, 'Thanks')\n    assert_includes(last_response.body, 'nothing is done')\n  end\n\n  def test_it_accepts_push_from_gitlab_to_not_default_master\n    headers = {\n      'CONTENT_TYPE' => 'application/json',\n      'HTTP_USER_AGENT' => 'GitLab 16.6',\n      'HTTP_X_GITLAB_EVENT' => 'Push Hook'\n    }\n    post(\n      '/hook/gitlab',\n      ['{\"checkout_sha\": \"da1560886d4\",',\n       '\"project\":{\"url\":\"localhost\",',\n       '\"default_branch\": \"main\",',\n       '\"path_with_namespace\":\"yegor256-one/com.github.0pdd-test\"},',\n       '\"ref\":\"refs/heads/main\"}'].join,\n      headers\n    )\n    assert_predicate(last_response, :ok?)\n    assert(last_response.body.start_with?('Thanks'))\n    refute_includes(last_response.body, 'nothing is done')\n  end\n\n  def test_it_ignores_push_from_gitlab_to_not_default_master\n    headers = {\n      'CONTENT_TYPE' => 'application/json',\n      'HTTP_USER_AGENT' => 'GitLab 16.6',\n      'HTTP_X_GITLAB_EVENT' => 'Push Hook'\n    }\n    post(\n      '/hook/gitlab',\n      ['{\"checkout_sha\": \"da1560886d4\",',\n       '\"project\":{\"url\":\"localhost\",',\n       '\"default_branch\": \"main\",',\n       '\"path_with_namespace\":\"yegor256-one/com.github.0pdd-test\"},',\n       '\"ref\":\"refs/heads/master\"}'].join,\n      headers\n    )\n    assert_predicate(last_response, :ok?)\n    assert_includes(last_response.body, 'Thanks')\n    assert_includes(last_response.body, 'nothing is done')\n  end\n\n  def test_renders_html_puzzles\n    get('/p?name=yegor256/pdd')\n    assert_predicate(last_response, :ok?)\n    html = last_response.body\n    assert(\n      html.include?('<html') &&\n        html.include?('<title>'),\n      \"broken HTML: #{html}\"\n    )\n  end\n\n  def test_snapshots_unavailable_repo\n    get('/snapshot?name=yegor256/0pdd_foobar_unavailable')\n    assert_equal(400, last_response.status)\n  end\n\n  def test_renders_svg_puzzles\n    get('/svg?name=yegor256/pdd')\n    assert_predicate(last_response, :ok?)\n    svg = last_response.body\n    File.write('/tmp/0pdd-button.svg', svg)\n    assert_includes(\n      svg, '<svg ',\n      \"broken SVG: #{svg}\"\n    )\n  end\n\n  def test_renders_xml_puzzles\n    get('/xml?name=yegor256/pdd')\n    assert_predicate(last_response, :ok?)\n    xml = last_response.body\n    assert_includes(\n      xml, '<puzzles ',\n      \"broken XML: #{xml}\"\n    )\n  end\n\n  def test_rejects_invalid_repo_name\n    get('/svg?name=yego256/pdd+a')\n    refute_predicate(last_response, :ok?)\n  end\n\n  def test_not_found\n    get('/unknown_path')\n    assert_equal(404, last_response.status)\n    assert_equal('text/html;charset=utf-8', last_response.content_type)\n  end\nend\n"
  },
  {
    "path": "test/test__helper.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nENV['RACK_ENV'] = 'test'\n\nrequire 'simplecov'\nrequire 'simplecov-cobertura'\nunless SimpleCov.running || ENV['PICKS']\n  SimpleCov.command_name('test')\n  SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new(\n    [\n      SimpleCov::Formatter::HTMLFormatter,\n      SimpleCov::Formatter::CoberturaFormatter\n    ]\n  )\n  SimpleCov.minimum_coverage 65\n  SimpleCov.minimum_coverage_by_file 10\n  SimpleCov.start do\n    add_filter 'test/'\n    add_filter 'vendor/'\n    add_filter 'target/'\n    track_files 'lib/**/*.rb'\n    track_files '*.rb'\n  end\nend\n\nrequire 'minitest/autorun'\nrequire 'minitest/reporters'\nMinitest::Reporters.use! [Minitest::Reporters::SpecReporter.new]\nMinitest.load :minitest_reporter\n\nrequire 'ostruct'\ndef object(hash)\n  json = hash.to_json\n  JSON.parse(json, object_class: OpenStruct)\nend\n"
  },
  {
    "path": "test/test_cached_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test__helper'\nrequire_relative 'fake_storage'\nrequire_relative '../objects/storage/cached_storage'\n\n# CachedStorage test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestCachedStorage < Minitest::Test\n  def test_simple_xml_loading\n    Dir.mktmpdir do |dir|\n      storage = CachedStorage.new(FakeStorage.new, File.join(dir, 'a/b/z.xml'))\n      storage.save(Nokogiri::XML('<test>hello</test>'))\n      assert_equal('hello', storage.load.xpath('/test/text()')[0].text)\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_commit_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'yaml'\nrequire_relative 'test__helper'\nrequire_relative '../objects/tickets/commit_tickets'\n\n# CommitTickets test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestCommitTickets < Minitest::Test\n  def test_submits_tickets\n    config = YAML.safe_load(\n      \"\nalerts:\n  suppress:\n    - on-found-puzzle\"\n    )\n    vcs = object(repo: { config: config })\n    tickets = Object.new\n    def tickets.submit(_)\n      {}\n    end\n    tickets = CommitTickets.new(vcs, tickets)\n    tickets.submit(nil)\n  end\n\n  def test_closes_tickets\n    config = YAML.safe_load(\n      \"\nalerts:\n  suppress:\n    - on-lost-puzzle\"\n    )\n    vcs = object(repo: { config: config })\n    tickets = Object.new\n    def tickets.close(_)\n      {}\n    end\n    tickets = CommitTickets.new(vcs, tickets)\n    tickets.close(nil)\n  end\n\n  def test_scope_suppressed_repo_should_be_quiet\n    config = YAML.safe_load(\n      \"\nalerts:\n  suppress:\n    - on-found-puzzle\"\n    )\n    vcs = object(repo: { config: config })\n    tickets = Object.new\n    def tickets.submit(_)\n      {}\n    end\n    tickets = CommitTickets.new(vcs, tickets)\n    tickets.submit(nil)\n  end\nend\n"
  },
  {
    "path": "test/test_credentials.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'mail'\nrequire 'yaml'\nrequire 'octokit'\nrequire 'tmpdir'\nrequire 'aws-sdk-dynamodb'\nrequire_relative 'test__helper'\nrequire_relative '../objects/storage/s3'\nrequire_relative '../objects/tickets/tickets'\nrequire_relative '../objects/log'\nrequire_relative '../objects/vcs/github'\nrequire_relative '../objects/git_repo'\n\nclass CredentialsTest < Minitest::Test\n  def test_connects_to_git_via_ssh\n    cfg = config\n    Dir.mktmpdir 'test' do |d|\n      repo = GitRepo.new(\n        uri: 'git@github.com:yegor256/0pdd',\n        name: 'yegor256/0pdd',\n        id_rsa: cfg['id_rsa'],\n        dir: d\n      )\n      repo.push\n      refute_nil(repo.xml.xpath('//puzzles'))\n    end\n  end\n\n  def test_connects_to_aws_dynamo\n    cfg = config\n    dynamo = Aws::DynamoDB::Client.new(\n      region: cfg['dynamo']['region'],\n      access_key_id: cfg['dynamo']['key'],\n      secret_access_key: cfg['dynamo']['secret']\n    )\n    refute(Log.new(dynamo, 'yegor256/0pdd').exists('some stupid tag'))\n  end\n\n  def test_connects_to_github\n    cfg = config\n    github = Octokit::Client.new(\n      access_token: cfg['github']['token']\n    )\n    tickets = Tickets.new(\n      GithubRepo.new(\n        github,\n        {\n          'repository' => {\n            'full_name' => 'yegor256/0pdd',\n            'url' => 'https://github.com/yegor256/0pdd',\n            'master_branch' => 'master'\n          },\n          'ref' => 'master',\n          'head_commit' => {\n            'id' => '---'\n          }\n        }\n      )\n    )\n    tickets.close(\n      Nokogiri::XML(\n        '<puzzle><id>AA</id><issue>1</issue></puzzle>'\n      ).xpath('/puzzle')\n    )\n  end\n\n  def test_connects_to_aws_s3\n    cfg = config\n    storage = S3.new(\n      'yegor256/0pdd.xml',\n      cfg['s3']['bucket'],\n      cfg['s3']['region'],\n      cfg['s3']['key'],\n      cfg['s3']['secret']\n    )\n    refute_nil(storage.load.xpath('//puzzles'))\n  end\n\n  def test_sends_email_via_smtp\n    cfg = config\n    Mail.defaults do\n      delivery_method(\n        :smtp,\n        address: cfg['smtp']['host'],\n        port: cfg['smtp']['port'],\n        user_name: cfg['smtp']['user'],\n        password: cfg['smtp']['password'],\n        domain: '0pdd.com',\n        enable_starttls_auto: true\n      )\n    end\n    mail = Mail.new do\n      from '0pdd <no-reply@0pdd.com>'\n      to 'admin@0pdd.com'\n      subject 'Test email, ignore it'\n      text_part do\n        content_type 'text/plain; charset=UTF-8'\n        body 'It it a test email, ignore it.'\n      end\n    end\n    mail.deliver!\n  end\n\n  private\n\n  def config\n    file = File.join(File.dirname(__FILE__), '../config.yml')\n    file = ENV['PDD_CONFIG'] if ENV['PDD_CONFIG']\n    skip('...') unless File.exist?(file)\n    YAML.safe_load(File.open(file))\n  end\nend\n"
  },
  {
    "path": "test/test_diff.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire 'ostruct'\nrequire_relative 'test__helper'\nrequire_relative '../objects/diff'\n\n# Diff test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestDiff < Minitest::Test\n  def test_notification_on_one_new_puzzle\n    tickets = Tickets.new\n    Diff.new(\n      Nokogiri::XML('<puzzles/>'),\n      Nokogiri::XML(\n        '<puzzles>\n          <puzzle alive=\"true\">\n            <id>1-abcdef</id>\n            <issue>5</issue>\n            <children>\n              <puzzle alive=\"true\">\n                <id>5-abcdef</id>\n                <issue href=\"#\">6</issue>\n                <ticket>5</ticket>\n                <children>\n                </children>\n              </puzzle>\n            </children>\n          </puzzle>\n        </puzzles>'\n      )\n    ).notify(tickets)\n    assert_equal(\n      1, tickets.messages.length,\n      \"Incorrect number of messages: #{tickets.messages.length}\"\n    )\n    assert_equal(\n      '5 the puzzle [#6](#) is still not solved.', tickets.messages[0],\n      \"Text is wrong: #{tickets.messages[0]}\"\n    )\n  end\n\n  def test_notification_unknown_issue\n    tickets = Tickets.new\n    xml = File.open('test-assets/puzzles/notify-unknown-open-issues.xml') do |f|\n      Nokogiri::XML(f)\n    end\n    Diff.new(Nokogiri::XML('<puzzles/>'), xml).notify(tickets)\n    assert_equal(\n      1, tickets.messages.length,\n      \"Incorrect number of messages: #{tickets.messages.length}\"\n    )\n    assert_equal(\n      '5 the puzzle [#125](//issue/125) is still not solved.', tickets.messages[0],\n      \"Text is wrong: #{tickets.messages[0]}\"\n    )\n  end\n\n  def test_notification_on_two_new_puzzles\n    tickets = Tickets.new\n    Diff.new(\n      Nokogiri::XML('<puzzles/>'),\n      Nokogiri::XML(\n        '<puzzles>\n          <puzzle alive=\"true\">\n            <id>1-abcdef</id>\n            <issue>55</issue>\n            <children>\n              <puzzle alive=\"true\">\n                <id>5-abcdee</id>\n                <issue href=\"#\">66</issue>\n                <ticket>55</ticket>\n                <children>\n                </children>\n              </puzzle>\n              <puzzle alive=\"true\">\n                <id>5-abcded</id>\n                <issue href=\"#\">77</issue>\n                <ticket>55</ticket>\n                <children>\n                </children>\n              </puzzle>\n            </children>\n          </puzzle>\n        </puzzles>'\n      )\n    ).notify(tickets)\n    assert_equal(\n      1, tickets.messages.length,\n      \"Incorrect number of messages: #{tickets.messages.length}\"\n    )\n    assert_equal(\n      '55 2 puzzles [#66](#), [#77](#) are still not solved.', tickets.messages[0],\n      \"Text is wrong: #{tickets.messages[0]}\"\n    )\n  end\n\n  def test_notification_on_solved_puzzle\n    tickets = Tickets.new\n    before = Nokogiri::XML(\n      '<puzzles>\n        <puzzle alive=\"true\">\n          <id>100-ffffff</id>\n          <issue>100</issue>\n          <ticket>500</ticket>\n        </puzzle>\n      </puzzles>'\n    )\n    after = Nokogiri::XML(before.to_s)\n    after.xpath('//puzzle[id=\"100-ffffff\"]')[0]['alive'] = 'false'\n    Diff.new(before, after).notify(tickets)\n    assert_equal(\n      1, tickets.messages.length,\n      \"Incorrect number of messages: #{tickets.messages.length}\"\n    )\n    assert_equal(\n      '500 the only puzzle [#100]() is solved here.', tickets.messages[0],\n      \"Text is wrong: #{tickets.messages[0]}\"\n    )\n  end\n\n  def test_notification_on_one_solved_puzzle\n    tickets = Tickets.new\n    before = Nokogiri::XML(\n      '<puzzles>\n        <puzzle alive=\"true\">\n          <id>100-1</id>\n          <issue>100</issue>\n          <ticket>999</ticket>\n        </puzzle>\n        <puzzle alive=\"false\">\n          <id>100-2</id>\n          <issue>101</issue>\n          <ticket>999</ticket>\n          <children>\n            <puzzle alive=\"true\">\n              <id>101-1</id>\n              <issue>13</issue>\n              <ticket>101</ticket>\n            </puzzle>\n          </children>\n        </puzzle>\n      </puzzles>'\n    )\n    after = Nokogiri::XML(before.to_s)\n    after.xpath('//puzzle[id=\"100-1\"]')[0]['alive'] = 'false'\n    Diff.new(before, after).notify(tickets)\n    assert_equal(\n      1, tickets.messages.length,\n      \"Wrong about of msgs (#{tickets.messages.length}): #{tickets.messages}\"\n    )\n    assert_equal(\n      '999 the puzzle [#13]() is still not solved; solved: [#100](), [#101]().', tickets.messages[0],\n      \"Text is wrong: #{tickets.messages[0]}\"\n    )\n  end\n\n  def test_notification_on_update\n    tickets = Tickets.new\n    before = Nokogiri::XML(\n      '<puzzles>\n        <puzzle alive=\"true\">\n          <id>1-abcdef</id>\n          <issue>5</issue>\n          <children>\n            <puzzle alive=\"true\">\n              <id>5-abcdef</id>\n              <issue href=\"#\">6</issue>\n              <ticket>5</ticket>\n            </puzzle>\n          </children>\n        </puzzle>\n      </puzzles>'\n    )\n    after = Nokogiri::XML(before.to_s)\n    after.xpath('//puzzle[id=\"5-abcdef\"]')[0]['alive'] = 'false'\n    Diff.new(before, after).notify(tickets)\n    assert_equal(\n      1, tickets.messages.length,\n      \"Wrong about of msgs (#{tickets.messages.length}): #{tickets.messages}\"\n    )\n    assert_equal(\n      '5 the only puzzle [#6](#) is solved here.', tickets.messages[0],\n      \"Text is wrong: #{tickets.messages[0]}\"\n    )\n  end\n\n  def test_quiet_when_no_changes\n    tickets = Tickets.new\n    xml = '<puzzles>\n      <puzzle alive=\"true\">\n        <id>1-abcdef</id>\n        <issue>50</issue>\n        <children>\n          <puzzle alive=\"true\">\n            <id>50-abcdef</id>\n            <issue href=\"#\">60</issue>\n            <children>\n            </children>\n          </puzzle>\n        </children>\n      </puzzle>\n    </puzzles>'\n    Diff.new(\n      Nokogiri::XML(xml),\n      Nokogiri::XML(xml)\n    ).notify(tickets)\n    assert_empty(tickets.messages)\n  end\n\n  class Tickets\n    attr_reader :messages\n\n    def initialize\n      @messages = []\n    end\n\n    def notify(ticket, text)\n      @messages << \"#{ticket} #{text}\"\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_diff_complicated.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire 'ostruct'\nrequire_relative 'test__helper'\nrequire_relative '../objects/diff'\n\n# Complicated diff test.\nclass TestDiff < Minitest::Test\n  # @todo #234:15m Add tests for more complicated dynamics, like\n  # [here](https://github.com/php-coder/mystamps/issues/695#issuecomment-405372820).\n  # Ideally, this tests other cases that can lead to the observed behaviour,\n  # but not covered by the test suite.\n\n  def test_notification_on_parent_solved_with_others_unsolved\n    tickets = Tickets.new\n    before = Nokogiri::XML(\n      '<puzzles>\n        <puzzle alive=\"true\">\n          <id>100-1</id>\n          <issue>100</issue>\n          <ticket>999</ticket>\n        </puzzle>\n        <puzzle alive=\"true\">\n          <id>100-2</id>\n          <issue>101</issue>\n          <ticket>999</ticket>\n          <children>\n            <puzzle alive=\"true\">\n              <id>101-1</id>\n              <issue>13</issue>\n              <ticket>101</ticket>\n            </puzzle>\n          </children>\n        </puzzle>\n      </puzzles>'\n    )\n    after = Nokogiri::XML(before.to_s)\n    after.xpath('//puzzle[id=\"100-2\"]')[0]['alive'] = 'false'\n    Diff.new(before, after).notify(tickets)\n    assert_equal(\n      1, tickets.messages.length,\n      \"Wrong about of msgs (#{tickets.messages.length}): #{tickets.messages}\"\n    )\n    assert_equal(\n      '999 2 puzzles [#100](), [#13]() are still not solved; solved: [#101]().', tickets.messages[0],\n      \"Text is wrong: #{tickets.messages[0]}\"\n    )\n  end\n\n  class Tickets\n    attr_reader :messages\n\n    def initialize\n      @messages = []\n    end\n\n    def notify(ticket, text)\n      @messages << \"#{ticket} #{text}\"\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_git_repo.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'tmpdir'\nrequire_relative 'test__helper'\nrequire_relative '../objects/git_repo'\nrequire_relative '../objects/user_error'\n\n# GitRepo test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestGitRepo < Minitest::Test\n  def test_clone_and_pull\n    Dir.mktmpdir 'test' do |d|\n      _, uri = git(d)\n      repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri)\n      repo.push\n      repo.push\n      assert_path_exists(File.join(repo.path, '.git'))\n    end\n  end\n\n  def test_merge_unrelated_histories\n    Dir.mktmpdir 'test' do |d|\n      path, uri = git(d, 'repo')\n      repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri)\n      repo.push\n      qbash(\"\n        set -e\n        cd '#{Shellwords.escape(path)}'\n        git checkout -b temp\n        git branch -D master\n        git checkout --orphan master\n        echo 'hello, dude!' > new.txt\n        git add new.txt\n        git commit --no-verify --quiet -am 'new master'\n      \")\n      repo.push\n      assert_path_exists(File.join(repo.path, 'new.txt'))\n    end\n  end\n\n  def test_fail_with_user_error\n    Dir.mktmpdir 'test' do |d|\n      path, uri = git(d, 'repo')\n      repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri)\n      repo.push\n      qbash(\n        \"\n        set -e\n        cd '#{Shellwords.escape(path)}'\n        echo '...\\x40todoBad puzzle' > z1.txt\n        echo '\\x40todo #1 Good puzzle' > z2.txt\n        git add z1.txt z2.txt\n        git commit --no-verify --quiet --amend --message 'zz'\n        \"\n      )\n      repo.push\n      assert_raises(UserError) do\n        repo.xml\n      end\n    end\n  end\n\n  def test_merge_after_amend\n    Dir.mktmpdir 'test' do |d|\n      path, uri = git(d, 'repo')\n      repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri)\n      repo.push\n      qbash(\"\n        set -e\n        cd '#{Shellwords.escape(path)}'\n        echo 'hello, dude!' > z.txt\n        git add z.txt\n        git commit --no-verify --quiet --amend --message 'new fix'\n      \")\n      repo.push\n      assert_path_exists(File.join(repo.path, 'z.txt'))\n    end\n  end\n\n  def test_merge_after_force_push\n    Dir.mktmpdir 'test' do |d|\n      path, uri = git(d, 'repo')\n      repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri)\n      repo.push\n      qbash(\"\n        set -e\n        cd '#{Shellwords.escape(path)}'\n        git reset HEAD~2\n        git reset --hard\n        git clean -fd\n        echo 'hello, dude!' >> z.txt && git add z.txt && git commit --no-verify -m ddd\n        echo 'hello, dude!' >> z.txt && git add z.txt && git commit --no-verify -m ddd\n        echo 'hello, dude!' >> z.txt && git add z.txt && git commit --no-verify -m ddd\n      \")\n      repo.push\n      assert_path_exists(File.join(repo.path, 'z.txt'))\n    end\n  end\n\n  def test_merge_after_complete_new_master\n    Dir.mktmpdir 'test' do |d|\n      path, uri = git(d, 'repo')\n      repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri)\n      repo.push\n      qbash(\"\n        set -e\n        cd '#{Shellwords.escape(path)}'\n        git checkout -b temp\n        git branch -D master\n        git checkout --orphan master\n        echo 'hello, new!' >> z.txt && git add z.txt && git commit --no-verify -m ddd\n        echo 'hello, new!' >> z.txt && git add z.txt && git commit --no-verify -m ddd\n        echo 'hello, new!' >> z2.txt && git add z2.txt && git commit --no-verify -m ddd\n      \")\n      repo.push\n      assert_path_exists(File.join(repo.path, 'z.txt'))\n      assert_path_exists(File.join(repo.path, 'z2.txt'))\n    end\n  end\n\n  def test_doesnt_touch_crlf\n    skip('...')\n    # I can't reproduce the problem of #125. The code works as it should\n    # be, however in production it fails due to some issues with CRLF\n    # in binary files.\n    # See also: https://stackoverflow.com/questions/46539254\n    Dir.mktmpdir 'test' do |d|\n      path, uri = git(d, 'repo')\n      repo = GitRepo.new(name: 'yegor256/pdd', dir: d, uri: uri)\n      qbash(\"\n        set -e\n        cd '#{Shellwords.escape(path)}'\n        git config --local core.autocrlf false\n        echo -n -e 'Hello, world!\\r\\nHow are you?' >> crlf.txt \\\n          && git add . && git commit --no-verify -am crlf.txt\n      \")\n      repo.push\n      assert_equal(\n        \"Hello, world!\\n\\rHow are you?\",\n        File.read(File.join(repo.path, 'crlf.txt'))\n      )\n    end\n  end\n\n  def test_push\n    Dir.mktmpdir 'test' do |d|\n      _, uri = git(d)\n      repo = GitRepo.new(name: 'teamed/est', dir: d, uri: uri)\n      repo.push\n      repo.push\n      assert_path_exists(File.join(repo.path, '.git'))\n    end\n  end\n\n  def test_fetch_puzzles\n    Dir.mktmpdir 'test' do |d|\n      _, uri = git(d)\n      repo = GitRepo.new(name: 'yegor256/0pdd', dir: d, uri: uri)\n      repo.push\n      refute_empty(repo.xml.xpath('/puzzles'))\n    end\n  end\n\n  def test_fetch_config\n    clean_dir = ''\n    begin\n      Dir.mktmpdir 'test' do |d|\n        clean_dir = d\n        _, uri = git(d)\n        repo = GitRepo.new(name: 'yegor256/0pdd', dir: d, uri: uri)\n        repo.push\n        assert(repo.config['foo'])\n      end\n    rescue Errno::ENOTEMPTY\n      FileUtils.remove_entry(clean_dir, true)\n    end\n  end\n\n  private\n\n  def git(dir, subdir = 'repo')\n    qbash(\"\n      set -e\n      cd '#{Shellwords.escape(dir)}'\n      git init --quiet #{Shellwords.escape(subdir)}\n      cd #{Shellwords.escape(subdir)}\n      git config user.email git@0pdd.com\n      git config user.name 0pdd\n      echo 'foo: hello' > .0pdd.yml\n      git add .0pdd.yml\n      git commit --no-verify --quiet -am 'add line'\n      echo 'hello, world!' >> z.txt && git add z.txt && git commit --no-verify -am z\n      echo 'hello, world!' >> z.txt && git add z.txt && git commit --no-verify -am z\n      echo 'hello, world!' >> z.txt && git add z.txt && git commit --no-verify -am z\n      echo 'hello, world!' >> z.txt && git add z.txt && git commit --no-verify -am z\n      echo 'hello, world!' >> z.txt && git add z.txt && git commit --no-verify -am z\n    \")\n    path = File.join(dir, subdir)\n    [path, \"file://#{path}\"]\n  end\nend\n"
  },
  {
    "path": "test/test_github.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test__helper'\nrequire_relative '../objects/clients/github'\n\n# Github test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestGithub < Minitest::Test\n  def test_configures_everything_right\n    github = Github.new.client\n    assert_equal('0pdd', github.user('0pdd')[:login],\n                 \"Real user is #{github.user('0pdd')[:login]}\")\n  end\nend\n"
  },
  {
    "path": "test/test_github_invitations.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test__helper'\nrequire_relative 'fake_github'\nrequire_relative '../objects/invitations/github_invitations'\n\n# GithubInvitations test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestGithubInvitation < Minitest::Test\n  def test_accepts_organization_invitations\n    organizations = %w[github google microsoft zerocracy]\n    orgs = %w[github zerocracy]\n    github = FakeGithub.new(\n      memberships: organizations.collect do |org|\n        {\n          'state' => orgs.include?(org) ? 'active' : 'pending',\n          'organization' => {\n            'login' => org\n          }\n        }\n      end\n    )\n    invitations = GithubInvitations.new(github)\n    invitations.accept_orgs\n    organizations.map do |org|\n      assert(\n        github.organization_memberships.find do |m|\n          m['state'] == 'active' && m['organization']['login'] == org\n        end\n      )\n    end\n  end\n\n  def test_accepts_repository_invitations\n    repositories = %w[yegor256/0pdd yegor256/sixnines]\n    github = FakeGithub.new(\n      invitations: repositories.enum_for(:each_with_index).collect do |repo, i|\n        {\n          'id' => i,\n          'repository' => {\n            'name' => repo\n          }\n        }\n      end\n    )\n    GithubInvitations.new(github).accept\n    repositories.map { |repo| assert_includes(github.repositories, repo) }\n  end\nend\n"
  },
  {
    "path": "test/test_github_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire 'yaml'\nrequire_relative 'test__helper'\nrequire_relative '../objects/tickets/tickets'\n\n# GithubTickets test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestGithubTickets < Minitest::Test\n  def test_submits_tickets\n    config = YAML.safe_load(\n      \"\nalerts:\n  github:\n    - yegor256\n    - davvd\nformat:\n  - short-title\n  - title-length=30\n        \"\n    )\n    repo = object(\n      name: 'github',\n      config: config,\n      head_commit_hash: '123',\n      master: 'master'\n    )\n    require_relative 'fake_github'\n    vcs = FakeGithub.new(repo: repo)\n    def vcs.create_issue(data)\n      @data = data\n      { number: 1, html_url: 'url' }\n    end\n    class << vcs\n      attr_accessor :data\n    end\n    tickets = Tickets.new(vcs)\n    tickets.submit(\n      Nokogiri::XML(\n        '<puzzle>\n          <id>23-ab536de</id>\n          <file>/a/b/c/test.txt</file>\n          <time>01-01-2019</time>\n          <author>yegor</author>\n          <body>привет дорогой друг, как твои дела?</body>\n          <ticket>123</ticket>\n          <estimate>30</estimate>\n          <role>DEV</role>\n          <lines>1-3</lines>\n        </puzzle>'\n      ).xpath('/puzzle')\n    )\n    assert_equal('привет дорогой друг, как...', vcs.data[:title])\n    assert(vcs.data[:description].start_with?('The puzzle `23-ab536de` from #123 has'))\n  end\n\n  def test_submits_tickets_log_title\n    config = YAML.safe_load(\"\\n\\n\")\n    repo = object(\n      name: 'github',\n      config: config,\n      head_commit_hash: '123',\n      master: 'master'\n    )\n    require_relative 'fake_github'\n    vcs = FakeGithub.new(repo: repo)\n    def vcs.create_issue(data)\n      @data = data\n      { number: 1, html_url: 'url' }\n    end\n    class << vcs\n      attr_accessor :data\n    end\n    tickets = Tickets.new(vcs)\n    tickets.submit(\n      Nokogiri::XML(\n        '<puzzle>\n          <id>55-ab536de</id>\n          <file>/a/bz.txt</file>\n          <time>01-05-2019</time>\n          <author>yegor</author>\n          <body>как дела? hey, how are you, please see this title!</body>\n          <ticket>123</ticket>\n          <estimate>30</estimate>\n          <role>DEV</role>\n          <lines>1-3</lines>\n        </puzzle>'\n      ).xpath('/puzzle')\n    )\n    assert_equal(\n      'bz.txt:1-3: как дела? hey, how are you, please see this...',\n      vcs.data[:title]\n    )\n    assert(vcs.data[:description].start_with?('The puzzle `55-ab536de` from #123 has'))\n  end\n\n  def test_output_estimates_when_it_is_not_zero\n    config = YAML.safe_load(\"\\n\\n\")\n    repo = object(\n      name: 'github',\n      config: config,\n      head_commit_hash: '123',\n      master: 'master'\n    )\n    require_relative 'fake_github'\n    vcs = FakeGithub.new(repo: repo)\n    def vcs.create_issue(data)\n      @data = data\n      { number: 1, html_url: 'url' }\n    end\n    class << vcs\n      attr_accessor :data\n    end\n    tickets = Tickets.new(vcs)\n    tickets.submit(\n      Nokogiri::XML(\n        '<puzzle>\n          <id>55-ab536de</id>\n          <file>/a/bz.txt</file>\n          <time>01-05-2019</time>\n          <author>yegor</author>\n          <body>как дела? hey, how are you, please see this title!</body>\n          <ticket>123</ticket>\n          <estimate>10</estimate>\n          <role>DEV</role>\n          <lines>1-3</lines>\n        </puzzle>'\n      ).xpath('/puzzle')\n    )\n    assert(vcs.data[:description].start_with?('The puzzle `55-ab536de` from #123 has'))\n    assert_includes(vcs.data[:description], 'Estimate:')\n  end\n\n  def test_skips_estimate_if_zero\n    config = YAML.safe_load(\"\\n\\n\")\n    repo = object(\n      name: 'github',\n      config: config,\n      head_commit_hash: '123',\n      master: 'master'\n    )\n    require_relative 'fake_github'\n    vcs = FakeGithub.new(repo: repo)\n    def vcs.create_issue(data)\n      @data = data\n      { number: 1, html_url: 'url' }\n    end\n    class << vcs\n      attr_accessor :data\n    end\n    tickets = Tickets.new(vcs)\n    tickets.submit(\n      Nokogiri::XML(\n        '<puzzle>\n          <id>55-ab536de</id>\n          <file>/a/bz.txt</file>\n          <time>01-05-2019</time>\n          <author>yegor</author>\n          <body>как дела? hey, how are you, please see this title!</body>\n          <ticket>123</ticket>\n          <estimate>0</estimate>\n          <role>DEV</role>\n          <lines>1-3</lines>\n        </puzzle>'\n      ).xpath('/puzzle')\n    )\n    assert(vcs.data[:description].start_with?('The puzzle `55-ab536de` from #123 has'))\n    refute_includes(vcs.data[:description], 'Estimate:')\n  end\n\n  def test_closes_tickets\n    config = YAML.safe_load(\"alerts:\\n  github:\\n    - yegor256\\n    - davvd\")\n    repo = object(\n      name: 'github',\n      config: config,\n      head_commit_hash: '123',\n      master: 'master'\n    )\n    require_relative 'fake_github'\n    tickets = Tickets.new(FakeGithub.new(repo: repo))\n    tickets.close(\n      Nokogiri::XML(\n        '<puzzle><id>xx</id><issue>1</issue></puzzle>'\n      ).xpath('/puzzle')\n    )\n  end\nend\n"
  },
  {
    "path": "test/test_gitlab.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test__helper'\nrequire_relative '../objects/clients/gitlab'\n\n# Github test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestGitlab < Minitest::Test\n  def test_configures_everything_right\n    gitlab = GitlabClient.new.client\n    assert_raises Gitlab::Error::MissingCredentials do\n      gitlab.user('0pdd')['username']\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_job.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire 'tmpdir'\nrequire_relative 'test__helper'\nrequire_relative 'fake_repo'\nrequire_relative 'fake_github'\nrequire_relative 'fake_tickets'\nrequire_relative 'fake_storage'\nrequire_relative '../objects/jobs/job'\nrequire_relative '../objects/storage/safe_storage'\n\n# Job test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestJob < Minitest::Test\n  def test_simple_scenario\n    Dir.mktmpdir 'test' do |d|\n      repo = FakeRepo.new\n      vcs = FakeGithub.new(repo: repo)\n      Job.new(\n        vcs,\n        SafeStorage.new(FakeStorage.new(d)),\n        FakeTickets.new\n      ).proceed\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_job_commiterrors.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test__helper'\nrequire_relative '../objects/jobs/job_commiterrors'\n\n# JobCommitErrors test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestJobCommitErrors < Minitest::Test\n  class Stub\n    attr_reader :name, :reported, :repo\n\n    def initialize(repo)\n      @repo = repo\n      @name = 'GITHUB'\n    end\n\n    def create_commit_comment(_, text)\n      @reported = text\n    end\n  end\n\n  def test_timeout_scenario\n    job = Object.new\n    def job.proceed\n      raise 'Intended to be here'\n    end\n    vcs = Stub.new(object(head_commit_hash: '123'))\n    begin\n      JobCommitErrors.new(vcs, job).proceed\n    rescue StandardError => e\n      refute_nil(e)\n    end\n    refute_empty(vcs.reported)\n  end\nend\n"
  },
  {
    "path": "test/test_job_detached.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test__helper'\nrequire_relative '../objects/jobs/job_detached'\n\n# JobDetached test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestJobDetached < Minitest::Test\n  def test_simple_scenario\n    job = Object.new\n    def job.proceed\n      # nothing\n    end\n    require_relative 'fake_repo'\n    vcs = object(repo: nil)\n    vcs.repo = FakeRepo.new\n    JobDetached.new(vcs, job).proceed\n  end\nend\n"
  },
  {
    "path": "test/test_job_emailed.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'veil'\nrequire_relative '../objects/jobs/job_emailed'\nrequire_relative 'fake_github'\nrequire_relative 'fake_repo'\nrequire_relative 'test__helper'\n\n# JobEmailed test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestJobEmailed < Minitest::Test\n  def fake_job\n    Veil.new(Object.new, proceed: nil)\n  end\n\n  def test_simple_scenario\n    repo = FakeRepo.new\n    vcs = FakeGithub.new(repo: repo)\n    job = fake_job\n    JobEmailed.new(vcs, job).proceed\n  end\n\n  def test_exception_mail_to_repo_owner_as_cc\n    skip('this test needs proper mocking')\n    repo = FakeRepo.new\n    vcs = FakeGithub.new(repo: repo)\n    job = fake_job\n    assert_raises(StandardError) do\n      JobEmailed.new(vcs, job).proceed\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_log.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire 'tmpdir'\nrequire_relative 'test__helper'\nrequire_relative '../objects/log'\nrequire_relative '../objects/dynamo'\n\n# Log test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestLog < Minitest::Test\n  def test_put_and_check\n    log = Log.new(Dynamo.new.aws, 'yegor256/0pdd')\n    tag = 'some-tag'\n    log.put(tag, 'some text here')\n    assert(log.exists(tag))\n  end\nend\n"
  },
  {
    "path": "test/test_logged_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test__helper'\nrequire_relative 'fake_storage'\nrequire_relative 'fake_log'\nrequire_relative '../objects/storage/logged_storage'\nrequire_relative '../objects/storage/versioned_storage'\n\n# LoggedStorage test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestLoggedStorage < Minitest::Test\n  def test_simple_xml_saving\n    storage = LoggedStorage.new(\n      VersionedStorage.new(FakeStorage.new, '0.0.1'), FakeLog.new\n    )\n    storage.save(Nokogiri::XML('<test>hello</test>'))\n    assert_equal('hello', storage.load.xpath('/test/text()')[0].text)\n  end\nend\n"
  },
  {
    "path": "test/test_logged_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire 'yaml'\nrequire_relative 'test__helper'\nrequire_relative 'fake_log'\nrequire_relative 'fake_tickets'\nrequire_relative '../objects/tickets/logged_tickets'\n\n# LoggedTickets test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestLoggedTickets < Minitest::Test\n  def test_submits_tickets\n    log = FakeLog.new\n    tickets = LoggedTickets.new('yegor256/0pdd', log, FakeTickets.new)\n    tickets.submit(\n      Nokogiri::XML(\n        '<puzzle>\n          <id>23-ab536de</id>\n          <file>/a/b/c/test.txt</file>\n          <body>hey!</body>\n          <lines>1-3</lines>\n        </puzzle>'\n      ).xpath('/puzzle')\n    )\n    assert_equal('23-ab536de/submit', log.tag)\n    assert_equal(\n      '23-ab536de submitted in issue #123: \"hey!\" at /a/b/c/test.txt; 1-3',\n      log.title\n    )\n  end\n\n  def test_closes_tickets\n    log = FakeLog.new\n    tickets = LoggedTickets.new('yegor256/0pdd', log, FakeTickets.new)\n    tickets.close(\n      Nokogiri::XML(\n        '<puzzle>\n          <id>23-ab536fe</id>\n          <issue>1</issue>\n        </puzzle>'\n      ).xpath('/puzzle')\n    )\n    assert_equal('23-ab536fe/closed', log.tag)\n    assert_equal(\n      '23-ab536fe closed in issue #1',\n      log.title\n    )\n  end\nend\n"
  },
  {
    "path": "test/test_maybe_text.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test__helper'\nrequire_relative '../objects/maybe_text'\n\n# Truncated test.\nclass TestMaybeText < Minitest::Test\n  def test_nil_input_then_blank\n    assert_equal('', MaybeText.new('output', nil).to_s)\n  end\n\n  def test_empty_input_then_blank\n    assert_equal('', MaybeText.new('output', '').to_s)\n  end\n\n  def test_excluded_input_then_blank\n    assert_equal('', MaybeText.new('output', 'exc', exclude_if: 'exc').to_s)\n  end\n\n  def test_present_input_then_output\n    assert_equal('output', MaybeText.new('output', 'input').to_s)\n  end\n\n  def test_show_output_when_exclude_if_is_present\n    assert_equal('output', MaybeText.new('output', 'input', exclude_if: 'output').to_s)\n  end\nend\n"
  },
  {
    "path": "test/test_milestone_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire 'yaml'\nrequire 'fake_github'\nrequire_relative 'test__helper'\nrequire_relative '../objects/tickets/milestone_tickets'\n\n# MilestoneTickets test.\n# Author:: George Aristy (george.aristy@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestGithubTickets < Minitest::Test\n  def test_sets_milestone\n    milestone = 123\n    config = YAML.safe_load(\n      \"\ntickets:\n  - inherit-milestone\nalerts:\n  suppress:\n    - on-inherited-milestone\n    \"\n    )\n    vcs = FakeGithub.new(repo: object(config: config))\n    def vcs.issue(_)\n      { milestone: { number: 123, title: 'v1.0' } }\n    end\n\n    def vcs.update_issue(_, options)\n      @milestone = options[:milestone]\n    end\n    class << vcs\n      attr_accessor :milestone\n    end\n    tickets = Object.new\n    def tickets.submit(_)\n      { number: 456, href: 'http://0pdd.com' }\n    end\n    test = MilestoneTickets.new(vcs, tickets)\n    test.submit(\n      Nokogiri::XML(\n        '<puzzle>\n          <id>23-ab536de</id>\n          <file>/a/b/c/test.txt</file>\n          <time>01-01-2019</time>\n          <author>yegor</author>\n          <body>привет дорогой друг, как твои дела?</body>\n          <ticket>456</ticket>\n          <estimate>30</estimate>\n          <role>DEV</role>\n          <lines>1-3</lines>\n        </puzzle>'\n      ).xpath('/puzzle')\n    )\n    assert_equal(milestone, vcs.milestone)\n  end\n\n  def test_does_not_set_milestone\n    config = YAML.safe_load(\n      '\nalerts:\n  suppress:\n    - on-inherited-milestone\n    '\n    )\n    vcs = FakeGithub.new(repo: object(config: config))\n    def vcs.issue(_)\n      { 'milestone' => { 'number' => 123, 'title' => 'v1.0' } }\n    end\n\n    def vcs.update_issue(_, _)\n      @updated = true\n    end\n    class << vcs\n      attr_accessor :updated\n    end\n    tickets = Object.new\n    def tickets.submit(_)\n      { number: 123, href: 'http://0pdd.com' }\n    end\n    test = MilestoneTickets.new(vcs, tickets)\n    test.submit(\n      Nokogiri::XML(\n        '<puzzle>\n          <id>23-ab536de</id>\n          <file>/a/b/c/test.txt</file>\n          <time>01-01-2019</time>\n          <author>yegor</author>\n          <body>привет дорогой друг, как твои дела?</body>\n          <ticket>123</ticket>\n          <estimate>30</estimate>\n          <role>DEV</role>\n          <lines>1-3</lines>\n        </puzzle>'\n      ).xpath('/puzzle')\n    )\n    refute(vcs.updated)\n  end\n\n  def test_adds_comment\n    config = YAML.safe_load(\n      '\ntickets:\n  - inherit-milestone\n'\n    )\n    vcs = FakeGithub.new(repo: object(config: config))\n    def vcs.issue(_)\n      { milestone: { number: 123, title: 'v1.0' } }\n    end\n\n    def vcs.update_issue(_, _)\n      # do nothing\n    end\n\n    def vcs.add_comment(_, text)\n      @comment = text\n    end\n    class << vcs\n      attr_accessor :comment\n    end\n    tickets = Object.new\n    def tickets.submit(_)\n      { number: 123, href: 'http://0pdd.com' }\n    end\n    test = MilestoneTickets.new(vcs, tickets)\n    test.submit(\n      Nokogiri::XML(\n        '<puzzle>\n          <id>23-ab536de</id>\n          <file>/a/b/c/test.txt</file>\n          <time>01-01-2019</time>\n          <author>yegor</author>\n          <body>привет дорогой друг, как твои дела?</body>\n          <ticket>123</ticket>\n          <estimate>30</estimate>\n          <role>DEV</role>\n          <lines>1-3</lines>\n        </puzzle>'\n      ).xpath('/puzzle')\n    )\n    assert(vcs.comment.start_with?('This puzzle inherited milestone'))\n  end\nend\n"
  },
  {
    "path": "test/test_once_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test__helper'\nrequire_relative 'fake_storage'\nrequire_relative '../objects/storage/once_storage'\n\n# OnceStorage test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestOnceStorage < Minitest::Test\n  def test_never_saves_duplicates\n    origin = TestStorage.new\n    storage = OnceStorage.new(origin)\n    storage.save(Nokogiri::XML('<test>hello</test>'))\n    assert_equal(0, origin.count)\n  end\n\n  def test_saves_only_once\n    origin = TestStorage.new\n    storage = OnceStorage.new(origin)\n    storage.save(Nokogiri::XML('<test>bye</test>'))\n    assert_equal(1, origin.count)\n  end\n\n  class TestStorage\n    attr_reader :count\n\n    def initialize\n      @count = 0\n    end\n\n    def load\n      Nokogiri::XML('<test>hello</test>')\n    end\n\n    def save(_)\n      @count += 1\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_puzzles.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire 'ostruct'\nrequire 'tmpdir'\nrequire_relative 'test__helper'\nrequire_relative 'fake_storage'\nrequire_relative 'fake_tickets'\nrequire_relative '../version'\nrequire_relative '../objects/git_repo'\nrequire_relative '../objects/puzzles'\nrequire_relative '../objects/storage/safe_storage'\nrequire_relative '../objects/storage/versioned_storage'\n\n# Puzzles test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestPuzzles < Minitest::Test\n  def test_all_xml\n    Dir.mktmpdir 'test' do |d|\n      test_xml(d, 'simple.xml')\n      test_xml(d, 'closes-one-puzzle.xml')\n      test_xml(d, 'ignores-unknown-issues.xml')\n      test_xml(d, 'submits-old-puzzles.xml')\n      test_xml(d, 'submits-three-tickets.xml')\n      # test_xml(d, 'submits-ranked-puzzles.xml', ordered: true)\n    end\n  end\n\n  def test_with_broken_tickets\n    tickets = Object.new\n    def tickets.submit(_)\n      nil\n    end\n    xml = File.open('test-assets/puzzles/simple.xml') { |f| Nokogiri::XML(f) }\n    Dir.mktmpdir 'test' do |dir|\n      Puzzles.new(\n        OpenStruct.new(\n          xml: Nokogiri.XML(xml.xpath('/test/snapshot/puzzles')[0].to_s),\n          config: {}\n        ),\n        FakeStorage.new(\n          dir,\n          Nokogiri.XML('<puzzles/>')\n        )\n      ).deploy(tickets)\n    end\n  end\n\n  private\n\n  def test_xml(dir, name, ordered: false)\n    xml = File.open(\"test-assets/puzzles/#{name}\") { |f| Nokogiri::XML(f) }\n    storage = VersionedStorage.new(\n      SafeStorage.new(\n        FakeStorage.new(\n          dir,\n          Nokogiri.XML(xml.xpath('/test/before/puzzles')[0].to_s)\n        )\n      ),\n      '0.0.1'\n    )\n    repo = OpenStruct.new(\n      xml: Nokogiri.XML(xml.xpath('/test/snapshot/puzzles')[0].to_s),\n      config: {}\n    )\n    tickets = FakeTickets.new\n    Puzzles.new(repo, storage).deploy(tickets)\n    xml.xpath('/test/assertions/xpath/text()').each do |xpath|\n      after = storage.load\n      refute_empty(\n        after.xpath(xpath.text),\n        \"#{xpath} not found in #{after}\"\n      )\n    end\n    xml.xpath('/test/submit/ticket/text()').each_with_index do |id, idx|\n      submitted = ordered ? tickets.submitted[idx] == id.text : tickets.submitted.include?(id.text)\n      assert(\n        submitted,\n        \"Puzzle #{id} was not submitted: #{tickets.submitted}\"\n      )\n    end\n    xml.xpath('/test/close/ticket/text()').each do |ticket|\n      assert_includes(\n        tickets.closed, ticket.text,\n        \"Ticket #{ticket} was not closed: #{tickets.closed}\"\n      )\n    end\n    tickets.closed.each do |ticket|\n      refute_empty(\n        xml.xpath(\"/test/close[ticket='#{ticket}']\"),\n        \"Ticket #{ticket} was closed by mistake\"\n      )\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_safe_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire_relative 'test__helper'\nrequire_relative 'fake_storage'\nrequire_relative 'fake_log'\nrequire_relative '../objects/storage/safe_storage'\n\n# SafeStorage test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestSafeStorage < Minitest::Test\n  def test_accepts_valid_xml\n    storage = SafeStorage.new(FakeStorage.new)\n    storage.save(\n      Nokogiri::XML(\n        '<?xml version=\"1.0\"?>\n        <puzzles xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n          xsi:noNamespaceSchemaLocation=\"https://www.0pdd.com/puzzles.xsd\"\n          date=\"2016-12-08T12:00:49Z\" version=\"0.0.0\">\n          <puzzle alive=\"true\">\n            <issue>unknown</issue>\n            <ticket>1</ticket>\n            <estimate>10</estimate>\n            <role>DEV</role>\n            <id>1-5e0e29d8</id>\n            <lines>7-7</lines>\n            <body>create mvvm model (see main page) for this page</body>\n            <file>attendance/lib/login_page.dart</file>\n            <author>@ammaratef45</author>\n            <email>a_atef_test@gmail-test.com</email>\n            <time>2019-01-16T17:08:45Z</time>\n            <children/>\n          </puzzle>\n        </puzzles>'\n      )\n    )\n  end\n\n  def test_rejects_invalid_xml\n    storage = SafeStorage.new(FakeStorage.new)\n    assert_raises(RuntimeError) do\n      storage.save(Nokogiri::XML('<test>hello</test>'))\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_sentry_tickets.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'mail'\nrequire_relative 'test__helper'\nrequire_relative '../objects/tickets/sentry_tickets'\n\n# SentryTickets test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestSentryTickets < Minitest::Test\n  def test_exception_catching_on_submit\n    tickets = Object.new\n    def tickets.submit(_)\n      raise 'submit failure'\n    end\n    assert_raises(StandardError) do\n      SentryTickets.new(tickets).submit(0)\n    end\n  end\n\n  def test_exception_catching_on_close\n    tickets = Object.new\n    def tickets.close(_)\n      raise 'close failure'\n    end\n    assert_raises(StandardError) do\n      SentryTickets.new(tickets).close(0)\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_svg.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire_relative 'test__helper'\n\n# SVG badge test.\nclass TestSvg < Minitest::Test\n  XSL = Nokogiri::XSLT(File.read(File.expand_path('../assets/xsl/svg.xsl', __dir__)))\n\n  def render(alive:, dead: 0)\n    body = ('<puzzle alive=\"true\"/>' * alive) + ('<puzzle alive=\"false\"/>' * dead)\n    XSL.transform(Nokogiri::XML(\"<puzzles>#{body}</puzzles>\")).to_s\n  end\n\n  def count_text(svg)\n    Nokogiri::XML(svg)\n      .xpath('//*[local-name()=\"text\" and @text-anchor=\"end\"]')\n      .first\n      .text\n  end\n\n  def badge_width(svg)\n    Nokogiri::XML(svg).root['width'].to_f\n  end\n\n  def test_renders_small_count\n    svg = render(alive: 5, dead: 2)\n    assert_equal('5/7', count_text(svg))\n    refute_includes(svg, '99+')\n  end\n\n  def test_renders_count_when_above_threshold\n    svg = render(alive: 150, dead: 50)\n    refute_includes(svg, '99+', \"badge must show real numbers, got: #{svg}\")\n    assert_equal('150/200', count_text(svg))\n  end\n\n  def test_renders_large_count\n    svg = render(alive: 1234, dead: 5678)\n    refute_includes(svg, '99+', \"badge must show real numbers, got: #{svg}\")\n    assert_equal('1234/6912', count_text(svg))\n  end\n\n  def test_widens_to_fit_large_numbers\n    [0, 5, 99, 100, 1000, 100_000].each do |alive|\n      svg = render(alive: alive)\n      width = badge_width(svg)\n      text = count_text(svg)\n      # 47 px label area + per-character advance ~6.5 + 7 px right padding\n      min = 47 + (text.length * 6.5) + 7\n      assert_operator(width, :>=, min,\n                      \"badge width #{width} too narrow for #{text.inspect} (alive=#{alive})\")\n    end\n  end\n\n  def test_text_anchor_stays_inside_badge\n    [0, 5, 99, 100, 1000, 100_000].each do |alive|\n      svg = render(alive: alive)\n      doc = Nokogiri::XML(svg)\n      width = doc.root['width'].to_f\n      doc.xpath('//*[local-name()=\"text\" and @text-anchor=\"end\"]').each do |t|\n        x = t['x'].to_f\n        assert_operator(x, :<=, width, \"anchor x=#{x} outside width=#{width} for alive=#{alive}\")\n        assert_operator(x, :>=, 47, \"anchor x=#{x} crosses into label area for alive=#{alive}\")\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_truncated.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire_relative 'test__helper'\nrequire_relative '../objects/truncated'\n\n# Truncated test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestTruncated < Minitest::Test\n  def test_simple_formatting\n    assert_equal('How...', Truncated.new('How are you?', 7).to_s)\n  end\n\n  def test_very_long_text\n    assert_equal(\n      'How are...',\n      Truncated.new('How are you? How are you? How are you? How are you?', 13).to_s\n    )\n  end\n\n  def test_short_long_text\n    assert_equal('Hey', Truncated.new('Hey', 13).to_s)\n  end\n\n  def test_unicode_text\n    assert_equal(\n      'Как дела?...',\n      Truncated.new(\"Как дела?\\n Как дела? \\nКак дела? \\n Как дела?\\n\", 13).to_s\n    )\n  end\n\n  def test_multi_line_text\n    assert_equal(\n      'First line Second...',\n      Truncated.new(\"  First   line  \\n  Second   line\\nThird line  \", 23).to_s\n    )\n  end\nend\n"
  },
  {
    "path": "test/test_upgraded_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire_relative 'test__helper'\nrequire_relative 'fake_storage'\nrequire_relative 'fake_log'\nrequire_relative '../objects/storage/safe_storage'\nrequire_relative '../objects/storage/upgraded_storage'\nrequire_relative '../objects/storage/versioned_storage'\n\n# UpgradedStorage test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestUpgradedStorage < Minitest::Test\n  def test_safety_preserved\n    fake = FakeStorage.new\n    fake.save(Nokogiri::XML('<puzzles/>'))\n    storage = UpgradedStorage.new(\n      SafeStorage.new(VersionedStorage.new(fake, '0.0.5')),\n      '0.0.5'\n    )\n    refute_empty(storage.load.xpath('/puzzles'))\n  end\n\n  def test_removes_broken_issues\n    storage = UpgradedStorage.new(FakeStorage.new, '0.0.1')\n    storage.save(\n      Nokogiri::XML(\n        '<puzzles><puzzle><id>X1</id><issue>123</issue></puzzle>\n        <puzzle><id>X2</id><issue/></puzzle><puzzles/>'\n      )\n    )\n    refute_empty(storage.load.xpath('//puzzle[id=\"X1\"]/issue'))\n    assert_empty(storage.load.xpath('//puzzle[id=\"X2\"]/issue'))\n  end\n\n  def test_removes_broken_href\n    storage = UpgradedStorage.new(FakeStorage.new, '0.0.2')\n    storage.save(\n      Nokogiri::XML(\n        '<puzzles><puzzle><id>X1</id><issue href=\"#\">123</issue></puzzle>\n        <puzzle><id>X2</id><issue>123</issue></puzzle><puzzles/>'\n      )\n    )\n    refute_empty(storage.load.xpath('//puzzle[id=\"X1\"]/issue/@href'))\n    assert_empty(storage.load.xpath('//puzzle[id=\"X2\"]/issue/@href'))\n  end\nend\n"
  },
  {
    "path": "test/test_versioned_storage.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nrequire 'nokogiri'\nrequire_relative 'test__helper'\nrequire_relative 'fake_storage'\nrequire_relative 'fake_log'\nrequire_relative '../objects/storage/versioned_storage'\n\n# VersionedStorage test.\n# Author:: Yegor Bugayenko (yegor256@gmail.com)\n# Copyright:: Copyright (c) 2016-2026 Yegor Bugayenko\n# License:: MIT\nclass TestVersionedStorage < Minitest::Test\n  def test_xml_versioning\n    version = '0.0.1'\n    storage = VersionedStorage.new(FakeStorage.new, version)\n    storage.save(Nokogiri::XML('<test>hello</test>'))\n    assert_equal(version, storage.load.xpath('/test/@version')[0].text)\n  end\nend\n"
  },
  {
    "path": "test-assets/puzzles/closes-one-puzzle.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<test>\n  <snapshot>\n    <puzzles date=\"2016-12-10T16:26:36Z\"/>\n  </snapshot>\n  <before>\n    <puzzles date=\"2016-12-03T16:26:36Z\" version=\"0.1\">\n      <puzzle alive=\"true\">\n        <issue>100</issue>\n        <ticket>23</ticket>\n        <estimate>15</estimate>\n        <role>IMP</role>\n        <id>TEST-ae347a22</id>\n        <lines>11-18</lines>\n        <body>Some other text of the other puzzle.</body>\n        <file>readme.txt</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-18T08:40:51Z</time>\n      </puzzle>\n    </puzzles>\n  </before>\n  <assertions>\n    <xpath>/puzzles[@date and @version]</xpath>\n    <xpath>/puzzles[count(//puzzle)=1]</xpath>\n    <xpath>/puzzles[count(//puzzle[@alive='true'])=0]</xpath>\n    <xpath>/puzzles[count(//puzzle[@alive='false'])=1]</xpath>\n    <xpath>//puzzle[id='TEST-ae347a22' and @alive='false']</xpath>\n  </assertions>\n  <submit/>\n  <close>\n    <ticket>TEST-ae347a22</ticket>\n  </close>\n</test>\n"
  },
  {
    "path": "test-assets/puzzles/ignores-unknown-issues.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<test>\n  <snapshot>\n    <puzzles date=\"2016-12-10T16:26:36Z\"/>\n  </snapshot>\n  <before>\n    <puzzles date=\"2016-12-03T16:26:36Z\" version=\"0.1\">\n      <puzzle alive=\"true\">\n        <issue>unknown</issue>\n        <ticket>23</ticket>\n        <estimate>15</estimate>\n        <role>IMP</role>\n        <id>23-ae347a22</id>\n        <lines>11-18</lines>\n        <body>Some other text of the other puzzle.</body>\n        <file>readme.txt</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-18T08:40:51Z</time>\n      </puzzle>\n    </puzzles>\n  </before>\n  <assertions>\n    <xpath>/puzzles[@date and @version]</xpath>\n  </assertions>\n  <submit/>\n  <close>\n    <ticket/>\n  </close>\n</test>\n"
  },
  {
    "path": "test-assets/puzzles/notify-unknown-open-issues.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<puzzles>\n  <puzzle alive=\"true\">\n    <id>1-abcdef</id>\n    <issue>5</issue>\n    <children>\n      <puzzle alive=\"true\">\n        <id>5-abcdef</id>\n        <issue href=\"//issue/125\">unknown</issue>\n        <ticket>5</ticket>\n      </puzzle>\n    </children>\n  </puzzle>\n</puzzles>\n"
  },
  {
    "path": "test-assets/puzzles/simple.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<test>\n  <snapshot>\n    <puzzles date=\"2016-12-10T16:26:36Z\">\n      <puzzle>\n        <ticket>516</ticket>\n        <estimate>15</estimate>\n        <role>IMP</role>\n        <id>516-ffc97ad1</id>\n        <lines>61-63</lines>\n        <body>Move header names from A to B.</body>\n        <file>src/test/java/Test.java</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-21T12:44:55Z</time>\n      </puzzle>\n    </puzzles>\n  </snapshot>\n  <before>\n    <puzzles>\n      <puzzle alive=\"true\">\n        <issue>100</issue>\n        <ticket>23</ticket>\n        <estimate>15</estimate>\n        <role>IMP</role>\n        <id>23-ae347a22</id>\n        <lines>11-18</lines>\n        <body>Some other text of the other puzzle.</body>\n        <file>readme.txt</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-18T08:40:51Z</time>\n      </puzzle>\n      <puzzle alive=\"false\">\n        <issue>516</issue>\n        <ticket>23</ticket>\n        <estimate>15</estimate>\n        <role>IMP</role>\n        <id>23-ffc97ad1</id>\n        <lines>12-16</lines>\n        <body>Some text of the puzzle.</body>\n        <file>readme.txt</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-18T08:40:51Z</time>\n      </puzzle>\n    </puzzles>\n  </before>\n  <assertions>\n    <xpath>/puzzles[@date]</xpath>\n    <xpath>/puzzles[count(//puzzle)=3]</xpath>\n    <xpath>/puzzles[count(//puzzle[@alive='true'])=1]</xpath>\n    <xpath>//puzzle[id='516-ffc97ad1' and @alive='true']</xpath>\n    <xpath>//puzzle[id='516-ffc97ad1' and issue='123']</xpath>\n    <xpath>//puzzle[id='23-ffc97ad1' and count(children/puzzle)=1]</xpath>\n  </assertions>\n  <submit>\n    <id>516-ffc97ad1</id>\n  </submit>\n  <close>\n    <ticket>23-ae347a22</ticket>\n    <ticket>23-ffc97ad1</ticket>\n  </close>\n</test>\n"
  },
  {
    "path": "test-assets/puzzles/submits-old-puzzles.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<test>\n  <snapshot>\n    <puzzles date=\"2016-12-10T16:26:36Z\">\n      <puzzle>\n        <ticket>516</ticket>\n        <estimate>60</estimate>\n        <role>IMP</role>\n        <id>516-ffdd7ae8</id>\n        <lines>61-63</lines>\n        <body>Move some files around.</body>\n        <file>src/test/java/Test.java</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-21T12:44:55Z</time>\n      </puzzle>\n      <puzzle>\n        <ticket>516</ticket>\n        <estimate>15</estimate>\n        <role>IMP</role>\n        <id>516-eedd7ae8</id>\n        <lines>61-63</lines>\n        <body>Move some files around.</body>\n        <file>src/test/java/Test.java</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-21T12:44:55Z</time>\n      </puzzle>\n    </puzzles>\n  </snapshot>\n  <before>\n    <puzzles date=\"2016-12-03T16:26:36Z\" version=\"0.1\">\n      <puzzle alive=\"true\">\n        <ticket>516</ticket>\n        <estimate>60</estimate>\n        <role>IMP</role>\n        <id>516-ffdd7ae8</id>\n        <lines>61-63</lines>\n        <body>Move some files around.</body>\n        <file>src/test/java/Test.java</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-21T12:44:55Z</time>\n        <children>\n          <puzzle alive=\"true\">\n            <issue>unknown</issue>\n            <ticket>516</ticket>\n            <estimate>15</estimate>\n            <role>IMP</role>\n            <id>516-eedd7ae8</id>\n            <lines>61-63</lines>\n            <body>Move some files around.</body>\n            <file>src/test/java/Test.java</file>\n            <author>yegor256</author>\n            <email>yegor@0pdd.com</email>\n            <time>2016-01-21T12:44:55Z</time>\n          </puzzle>\n        </children>\n      </puzzle>\n    </puzzles>\n  </before>\n  <assertions>\n    <xpath>/puzzles[@date and @version]</xpath>\n    <xpath>/puzzles[count(//puzzle)=2]</xpath>\n    <xpath>//puzzle[id='516-ffdd7ae8' and issue]</xpath>\n    <xpath>//puzzle[id='516-eedd7ae8' and issue!='unknown']</xpath>\n  </assertions>\n  <submit>\n    <ticket>516-eedd7ae8</ticket>\n    <ticket>516-ffdd7ae8</ticket>\n  </submit>\n  <close/>\n</test>\n"
  },
  {
    "path": "test-assets/puzzles/submits-ranked-puzzles.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<test>\n  <snapshot>\n    <puzzles date=\"2016-12-10T16:26:36Z\" model=\"true\">\n      <puzzle>\n        <ticket>516</ticket>\n        <estimate>60</estimate>\n        <role>IMP</role>\n        <id>516-ffdd7ae8</id>\n        <lines>61-63</lines>\n        <body>Move some files around.</body>\n        <file>src/test/java/Test.java</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-21T12:44:55Z</time>\n      </puzzle>\n      <puzzle>\n        <ticket>517</ticket>\n        <estimate>15</estimate>\n        <role>IMP</role>\n        <id>517-ffdd7ae9</id>\n        <lines>61-63</lines>\n        <body>Move some files around.</body>\n        <file>src/test/java/Test.java</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-21T12:44:55Z</time>\n      </puzzle>\n      <puzzle>\n        <ticket>518</ticket>\n        <estimate>30</estimate>\n        <role>IMP</role>\n        <id>518-ffdd7aea</id>\n        <lines>61-63</lines>\n        <body>Move some files around.</body>\n        <file>src/test/java/Test.java</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-21T12:44:55Z</time>\n      </puzzle>\n    </puzzles>\n  </snapshot>\n  <before>\n    <puzzles date=\"2016-12-03T16:26:36Z\" version=\"0.1\" model=\"true\"/>\n  </before>\n  <assertions>\n    <xpath>/puzzles[count(//puzzle)=3]</xpath>\n  </assertions>\n  <submit>\n    <ticket>517-ffdd7ae9</ticket>\n    <ticket>518-ffdd7aea</ticket>\n    <ticket>516-ffdd7ae8</ticket>\n  </submit>\n  <close/>\n</test>\n"
  },
  {
    "path": "test-assets/puzzles/submits-three-tickets.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n * SPDX-License-Identifier: MIT\n-->\n<test>\n  <snapshot>\n    <puzzles date=\"2016-12-10T16:26:36Z\">\n      <puzzle>\n        <ticket>516</ticket>\n        <estimate>60</estimate>\n        <role>IMP</role>\n        <id>516-ffdd7ae8</id>\n        <lines>61-63</lines>\n        <body>Move some files around.</body>\n        <file>src/test/java/Test.java</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-21T12:44:55Z</time>\n      </puzzle>\n      <puzzle>\n        <ticket>517</ticket>\n        <estimate>15</estimate>\n        <role>IMP</role>\n        <id>517-ffdd7ae9</id>\n        <lines>61-63</lines>\n        <body>Move some files around.</body>\n        <file>src/test/java/Test.java</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-21T12:44:55Z</time>\n      </puzzle>\n      <puzzle>\n        <ticket>518</ticket>\n        <estimate>30</estimate>\n        <role>IMP</role>\n        <id>518-ffdd7aea</id>\n        <lines>61-63</lines>\n        <body>Move some files around.</body>\n        <file>src/test/java/Test.java</file>\n        <author>yegor256</author>\n        <email>yegor@0pdd.com</email>\n        <time>2016-01-21T12:44:55Z</time>\n      </puzzle>\n    </puzzles>\n  </snapshot>\n  <before>\n    <puzzles date=\"2016-12-03T16:26:36Z\" version=\"0.1\"/>\n  </before>\n  <assertions>\n    <xpath>/puzzles[count(//puzzle)=3]</xpath>\n  </assertions>\n  <submit>\n    <ticket>517-ffdd7ae9</ticket>\n    <ticket>518-ffdd7aea</ticket>\n    <ticket>516-ffdd7ae8</ticket>\n  </submit>\n  <close/>\n</test>\n"
  },
  {
    "path": "version.rb",
    "content": "# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n# SPDX-License-Identifier: MIT\n\nVERSION = 'BUILD'.freeze\n"
  },
  {
    "path": "views/_footer.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%p\n  ='--'\n%p\n  =ver\n"
  },
  {
    "path": "views/_header.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%p\n  %a{href:'/'}\n    %img.logo{src:'https://avatars2.githubusercontent.com/u/24456188'}\n%p\n  - if defined? user\n    = \"@#{user[:login]}\"\n    = '|'\n    %a{href: '/logout'}\n      Logout\n  - else\n    %a{href: login_link}\n      Login\n%p\n  %a{href: \"/p?name=#{repo}\"}\n    = repo\n"
  },
  {
    "path": "views/error.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%p\n  %a{href:'/'}\n    %img.logo{src:'https://avatars2.githubusercontent.com/u/24456188'}\n%p{style:'color: red;'}\n  Internal error at\n  =ver\n%p\n  Please help us improve our service and submit the following\n  stacktrace to our issue tracker in GitHub:\n  %a{href:'https://github.com/yegor256/0pdd'}\n    yegor256/0pdd\n%pre\n  &=error\n"
  },
  {
    "path": "views/error_400.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%p\n  Request error\n%pre\n  = error_message\n"
  },
  {
    "path": "views/index.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%div.center\n  %p\n    %a{href:url('/')}\n      %img.logo{src:'/images/logo.svg'}\n  %p\n    PDD puzzles collector.\n  %p\n    %a{href:'http://www.yegor256.com/2017/04/05/pdd-in-action.html'} How does it work?\n  - if !tail.empty?\n    %p\n      Recent commits:\n      %br\n      - tail.each_with_index do |r|\n        %span\n          %a{href:'https://github.com/' + r}>= r\n  %p\n    Made by\n    %a{href:'https://www.yegor256.com'} @yegor256\n    for\n    = succeed \".\" do\n      %a{href:'https://www.zerocracy.com'} Zerocracy\n  %p\n    %a{href:'https://www.sixnines.io/h/574a'}\n      %img{src:'https://www.sixnines.io/b/574a'}>\n    %a{href:'https://www.rehttp.net/i?u=http%3A%2F%2Fwww.0pdd.com%2Fhook%2Fgithub'}\n      %img{src:'https://www.rehttp.net/b?u=http%3A%2F%2Fwww.0pdd.com%2Fhook%2Fgithub'}>\n  %p\n    %a{href:'https://github.com/yegor256/0pdd'}\n      %img{src:'https://img.shields.io/github/stars/yegor256/0pdd.svg'}\n  %p.versions\n    %img{src:url('/images/logo.svg'), alt:'Currently deployed version'}\n    %span{title:'Currently deployed version'}\n      = ver\n    %img{src:url('/images/git-logo.svg'), alt:'Git version on the server'}\n    %span{title:'Git version on the server'}\n      = git_version\n    %img{src:url('/images/ruby-logo.svg'), alt:'Ruby version on the server'}\n    %span{title:'Ruby version on the server'}\n    = ruby_version\n  %p\n    %span{style: 'color:' + (remaining < 1000 ? 'red' : 'green'), title: 'GitHub API remaining quota'}= remaining\n"
  },
  {
    "path": "views/item.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n= Haml::Engine.new(File.read('views/_header.haml')).render(Object.new, local_assigns)\n\n%p\n  This is a single entry in the log. All entries have unique\n  mnemo \"tags\", in order to prevent duplicate events from\n  happening.\n%p\n  Repository:\n  = repo\n%p\n  Tag:\n  = item['tag']\n%p\n  Time:\n  = Time.at(item['time']).iso8601\n%p\n  Details:\n  = item['text']\n\n- if defined?(user) && user[:login] == 'yegor256'\n  %p\n    %a{href:url(\"/log-delete?name=#{repo}&time=#{item['time'].to_i}&tag=#{item['tag']}\"), onclick: \"return confirm('You are going to delete the \\\"#{item['tag']}\\\" event. Normally you should not do this. Are you sure?')\"}\n      delete it\n\n= Haml::Engine.new(File.read('views/_footer.haml')).render(Object.new, local_assigns)\n"
  },
  {
    "path": "views/layout.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n!!! 5\n%html\n  %head\n    %title\n      =title\n    %meta{charset:'UTF-8'}\n    %meta{name:'viewport', content:'width=device-width, initial-scale=1.0'}\n    %meta{name:'keywords', content:'PDD, GitHub, Puzzle Driven Development'}\n    %meta{name:'description', content:'Hosted PDD puzzles collector'}\n    %link{type:'text/css', href:url('css/main.css'), rel:'stylesheet'}\n    %link{rel:'shortcut icon', href:'https://avatars2.githubusercontent.com/u/24456188'}\n  %body\n    = yield\n    :javascript\n      (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){\n      (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),\n      m=s.getElementsByTagName(o)[0].text;a.async=1;a.src=g;m.parentNode.insertBefore(a,m)\n      })(window,document,'script','//www.google-analytics.com/analytics.js','ga');\n      ga('create', 'UA-1963507-49', 'auto');\n      ga('send', 'pageview');\n"
  },
  {
    "path": "views/log.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n= Haml::Engine.new(File.read('views/_header.haml')).render(Object.new, local_assigns)\n\n%p\n  This log is here for safety reason. Every time\n  0pdd is touching your GitHub repository, this log gets\n  a unique record. Later, if 0pdd will try to submit a similar\n  issue by mistake, this log will prevent that from happening,\n  because all records here must be unique.\n%p\n  Since\n  = Time.at(since).iso8601\n- log_list = log.list(since)\n- events = log_list.items\n- if events.empty?\n  %p\n    There are no events yet.\n- else\n  - next_page = nil\n  - events.each do |e|\n    - next_page = e['time'].to_i\n    %p\n      %a{href:url(\"/log-item?repo=#{repo}&tag=#{e['tag']}\")}>= Time.at(e['time']).iso8601\n      = ':'\n      = e['text']\n      - if defined?(user) && user[:login] == 'yegor256'\n        %a{href:url(\"/log-delete?name=#{repo}&time=#{e['time'].to_i}&tag=#{e['tag']}\"), onclick: \"return confirm('You are going to delete the \\\"#{e['tag']}\\\" event. Normally you should not do this. Are you sure?')\"}\n          delete\n  - unless log_list.last_evaluated_key.nil?\n    %p\n      %a{href:url(\"/log?name=#{repo}&since=#{next_page}\")}\n        = 'more'\n\n= Haml::Engine.new(File.read('views/_footer.haml')).render(Object.new, local_assigns)\n"
  },
  {
    "path": "views/not_found.haml",
    "content": "-# SPDX-FileCopyrightText: Copyright (c) 2016-2026 Yegor Bugayenko\n-# SPDX-License-Identifier: MIT\n\n%p\n  This page is not here.\n  Most likely the link is broken or expired.\n"
  }
]