diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..9a883d851 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,38 @@ +--- +# Use the latest 2.1 version of CircleCI pipeline process engine. See: +# https://circleci.com/docs/2.0/configuration-reference +version: 2.1 +orbs: + codecov: codecov/codecov@4.1.0 +# Orchestrate or schedule a set of jobs +workflows: + docker-compose: + jobs: + - build-and-test +jobs: + build-and-test: + machine: true + resource_class: large + steps: + - run: + name: docker compose version + command: docker compose version + - checkout + - run: + name: create coverage directory + command: | + mkdir cover_db + chmod o+w cover_db + - run: + name: docker compose build + command: | + docker compose --profile test build api-test + - run: + name: run tests with coverage + command: | + docker compose --profile test run --env HARNESS_PERL_SWITCHES=-MDevel::Cover -v ./cover_db:/app/cover_db/ api-test bash -c 'prove -lr -j4 t && cover -report codecovbash' + # We are relying on environment variables from the host to be available when + # we publish the report, so we publish from the host rather than trying + # to propagate env variables to the container. + - codecov/upload: + file: cover_db/codecov.json diff --git a/.dockerignore b/.dockerignore new file mode 120000 index 000000000..3e4e48b0b --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.gitignore \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..7ff72b0cc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] + +indent_style = space +indent_size = 4 + +# We recommend you to keep these unchanged +end_of_line = lf +charset = utf-8 + +# I'd like to enable this, but we should fix all the files first to avoid diff noise. +#trim_trailing_whitespace = true +insert_final_newline = true + +# yaml indents are weird +[*.{yml,yaml}] +indent_size = 2 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..0a9225563 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,143 @@ +# How to contribute + +We are always after more contributors and suggestions. + +### How can I help? + +The following issues are tagged as [Volunteer needed](https://github.com/CPAN-API/cpan-api/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+label%3A%22Volunteer+needed%22+no%3Aassignee+) + +## Suggestions or issues with MetaCPAN... + +#### Does it relate to our API (backend)... ? + + 1. Please check the [previously reported API issues](https://github.com/CPAN-API/cpan-api/issues) + 2. Please check the [Wishlist](https://github.com/CPAN-API/cpan-api/wiki/Wishlist). If you can't find it already there: + * If it's a wishlist idea, please edit the [wiki](https://github.com/CPAN-API/cpan-api/wiki/Wishlist) (add a 'wishlist_MYIDEA' page if you need more space!) + * If it's an actual bug [create a new issue](https://github.com/CPAN-API/cpan-api/issues/new) + +#### If you are not sure, or it is related to https://metacpan.org/ front end: + + 1. Please check the [previously reported Web issues](https://github.com/CPAN-API/metacpan-web/issues) + 2. Please check the [Wishlist](https://github.com/CPAN-API/cpan-api/wiki/Wishlist). If you can't find it already there: + * If it's a wishlist idea, please edit the [wiki](https://github.com/CPAN-API/cpan-api/wiki/Wishlist) (add a 'wishlist_MYIDEA' page if you need more space!) + * If it's an actual bug [create a new issue](https://github.com/CPAN-API/metacpan-web/issues/new) + +## Contributing code + +Come talk to us on IRC (see below), or send a pull request and we'll respond +there. If you implement a new feature, please add a note about it to the +News.md file at the top level of metacpan-web so that it will appear in our +news feed. + +If you aren't using the VM, remember to enable the pre-commit hook before you start working. + + sh git/setup.sh + +These links will get you going quickly: + + * [Using our developer VM](https://github.com/CPAN-API/metacpan-developer) to get you going in minutes (depending on bandwidth) + * [Front end bug list](https://github.com/CPAN-API/metacpan-web/issues) + * [API (back end) bug list](https://github.com/CPAN-API/cpan-api/issues) + * [Wishlist](https://github.com/CPAN-API/cpan-api/wiki/Wishlist) - things that probably need doing + +# Git workflow + +We try to keep a clean git history, so if it all possible, please rebase to get +the latest changes from master _before_ submitting a pull request. You'll only +need to do the first command (git remote add) once in your local checkout. + + git remote add upstream https://github.com/CPAN-API/metacpan-web.git + git pull --rebase upstream master + +If you are comfortable rebasing, it is also helpful to squash or delete commits +which are no longer relevant to your branch before submitting your work. + + git rebase -i master + +If you are not comfortable with rebasing, but want to use it, check out the steps +from [here](https://help.github.com/articles/using-git-rebase/). + +# Coding conventions + +Please try to follow the conventions already been used in the code base. This +will generally be the right thing to do. Our standards are improving, so even +if you do follow what you see, we may ask you to make some changes, but that is +a good thing. We are trying to keep things tidy. + +If you are using the [developer VM](https://github.com/CPAN-API/metacpan-developer) you can run: + +```sh +/home/vagrant/carton/metacpan-web/bin/tidyall +``` + +## Perl Best Practices + +In general, the concepts discussed in "Perl Best Practices" are a good starting +point. Use autodie where possible and MetaCPAN::Web::Types when creating new +Moose attributes. Many of the other standards will be enforced by Perl::Critic. + +## Clear > Concise + +Take pains to use variable names which are easy to understand and to write +readable code. We value readable code over concise code. Use singular nouns +for class names. Use verbs for method names. + +## Try::Tiny > eval { ... } + +You will see many eval statements in the code. We would like to standardize on +Try::Tiny, so feel free to swap out any eval with a Try::Tiny and use Try::Tiny +in all new code. + +## Prefer single quotes + +Always use single quotes in cases where there is no variable interpolation. If +there is a single quote in the quoted item, use curly quotes. + +q{Isn't this a lovely day}; + +## Include a test (or more!) + +Any time when a pull request includes a test, it makes it easier for us to +review and accept, so please do test your changes whenever possible. If your +pull request includes visual changes, please include a before and after screen +shot, so that we can better understand the problem you're trying to solve. + +## Dependencies + +Introducing new dependencies is fine, if they solve a specific problem which +current dependencies cannot address. If we prefer a different module to be used, +we'll let you know. + +## It's OK to be controversial + +If a pull request contains any controversial changes, we'll likely wait for some +feedback from several developers before a merge. If you think your changes may +be controversial, feel free to discuss them in a Github issue before starting to +write any code. + +## Travis is your friend + +We use Travis to test all code changes. After submitting your pull request, +remember to check back to see whether Travis has come back with any test +failures. We do get some false negatives. If your pull request failed for +reasons unrelated to your changes, we may still be able to merge your work. + +# Additional Resources + + * [\#metacpan](http://widget01.mibbit.com/?autoConnect=true&server=irc.perl.org&channel=%23metacpan&nick=) IRC channel on irc.perl.org + +# Current Policies + +### What is indexed? + + * Perl distributions which contain Perl packages. + +### When are issues closed? + +We want to keep the issue list manageable, so we can focus on what actually +needs fixing. If you feel an issue needs opening again, please add a comment +explaining why it needs re-opening and we'll look at it again. + + * Issues will be closed and moved to [Wishlist](https://github.com/CPAN-API/cpan-api/wiki/Wishlist) if they are not actual bugs + * Issues we think we have addressed will be closed + * Issues we are not going to take any further action on without more information will be closed diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..326ba5a61 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,18 @@ +# Important, please read: + +MetaCPAN's core developers need to focus on fixing bugs and improving the +existing core system. + +For this reason, if you have a feature which you would like to see added (there +are loads we would love to have), please only open an issue _IF_ you are +prepared to do the work to implement it. To be clear, we'd love to have a +bunch of really cool, new, features, but it's more important for us to focus on +keeping MetaCPAN humming along. + +If you're not motivated or otherwise able to send a pull request for your cool, +new feature, please add it to our wishlist: +https://github.com/CPAN-API/cpan-api/wiki/Wishlist and someone may get to it +one day. Maybe that person will be you! + +For more details on issues and contributing please see CONTRIBUTING.md (linked +above). diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..675a53345 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +--- +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + # Check for updates to GitHub Actions every week + interval: 'weekly' diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 000000000..a9b56fc7e --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,22 @@ +name: Enable Auto-Merge For bots +on: + pull_request_target: + types: [opened] + +jobs: + enable-auto-merge: + runs-on: ubuntu-latest + if: > + github.event.pull_request.user.login == 'metacpan-automation[bot]' + || github.event.pull_request.user.login == 'dependabot[bot]' + steps: + - name: Generate Auth Token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: peter-evans/enable-pull-request-automerge@v3 + with: + token: ${{ steps.app-token.outputs.token }} + pull-request-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml new file mode 100644 index 000000000..00d325bee --- /dev/null +++ b/.github/workflows/build-container.yml @@ -0,0 +1,44 @@ +name: Build container +on: + push: + branches: + - master + - staging + - prod + pull_request: + types: [opened, synchronize, labeled] + branches: + - master + workflow_dispatch: +jobs: + docker-build: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'build-container') + runs-on: ubuntu-22.04 + name: Docker Build and Push + steps: + - name: Generate Auth Token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: metacpan + - uses: actions/checkout@v5 + with: + token: ${{ steps.app-token.outputs.token }} + - uses: metacpan/metacpan-actions/docker-build-push@master + id: build-push + with: + docker_hub_username: ${{ secrets.DOCKER_HUB_USER }} + docker_hub_password: ${{ secrets.DOCKER_HUB_TOKEN }} + ghcr_username: ${{ github.repository_owner }} + ghcr_password: ${{ secrets.GITHUB_TOKEN }} + - name: Update deployed image + if: ${{ fromJSON(steps.build-push.outputs.tag-fq).latest }} + uses: metacpan/metacpan-actions/update-deployed-tag@master + with: + token: ${{ steps.app-token.outputs.token }} + app: api + environment: prod + base-tag: ${{ fromJSON(steps.build-push.outputs.tag-fq).latest }} + tag: ${{ fromJSON(steps.build-push.outputs.tag-fq).sha }} diff --git a/.github/workflows/code-formatting.yml b/.github/workflows/code-formatting.yml new file mode 100644 index 000000000..b833d372c --- /dev/null +++ b/.github/workflows/code-formatting.yml @@ -0,0 +1,49 @@ +--- +name: Code Formatting +on: + push: + branches: + - 'master' + merge_group: + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + code-formatting: + runs-on: ubuntu-24.04 + name: Code Formatting + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Fetch base ref + if: ${{ github.event.pull_request }} + run: git fetch origin ${{ github.base_ref }}:upstream + - name: Install Carton + uses: perl-actions/install-with-cpm@v1 + with: + install: Carton + - name: Install CPAN deps + uses: perl-actions/install-with-cpm@v1 + with: + cpanfile: 'cpanfile' + args: > + --resolver=snapshot + --with-develop + - name: Install precious + run: ./bin/install-precious /usr/local/bin + env: + GITHUB_TOKEN: ${{ github.token }} + - run: perltidy --version + - name: Select files + id: select-files + run: | + if [[ -n "${{ github.event.pull_request.number }}" ]]; then + echo 'precious-args=--git-diff-from upstream' >> "$GITHUB_OUTPUT" + else + echo 'precious-args=--all' >> "$GITHUB_OUTPUT" + fi + - name: Lint files + run: precious lint ${{ steps.select-files.outputs.precious-args }} diff --git a/.github/workflows/update-snapshot.yml b/.github/workflows/update-snapshot.yml new file mode 100644 index 000000000..816301081 --- /dev/null +++ b/.github/workflows/update-snapshot.yml @@ -0,0 +1,35 @@ +name: Update cpanfile.snapshot +on: + schedule: + - cron: "1 15 * * 0" + workflow_dispatch: +jobs: + update-dep: + runs-on: "ubuntu-22.04" + container: + image: perl:5.22-buster + steps: + - name: Generate Auth Token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: haarg/setup-git-user@v1 + with: + app: ${{ steps.app-token.output.app-slug }} + - uses: actions/checkout@v5 + with: + token: ${{ steps.app-token.outputs.token }} + - name: Update cpanfile.snapshot + uses: metacpan/metacpan-actions/update-snapshot@master + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.app-token.outputs.token }} + commit-message: Update cpanfile.snapshot + title: Update cpanfile.snapshot + sign-commits: true + body: | + [GitHub Action Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + branch: update-cpanfile-snapshot diff --git a/.gitignore b/.gitignore index 3408cbd20..cd6e79b24 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,25 @@ -*.kpf +/MYMETA.* +/Makefile +/Makefile.old +/blib +/cover_db/ +/local/ +/log4perl_local.conf +/metacpan_server_local.* +/metacpan_server_testing_local.* +/perltidy.LOG +/pm_to_blib +/var +/bin/omegasort +/bin/precious +/bin/ubi +/etc/metacpan_local.pl +/t/var/darkpan/ +/t/var/log/ +/t/var/tmp/ *.komodoproject +*.kpf *.sqlite* +*.sw* +.DS_Store +.tidyall.d diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000..8c9325def --- /dev/null +++ b/.mailmap @@ -0,0 +1,53 @@ +# shared between metacpan-web and metacpan-api + +Amrita Mathew +Andreas Marienborg +Andreea Pirvulescu +Andreea Pirvulescu +Andrew Fresh +Andrew Fresh +Barry Walsh +Brad Lhotsky +Chris Nehren +Christopher White +Christopher White +Christopher White +Clinton Gormley +Ed J +Gabor Szabo +Grant McLean +Grant McLean +J. Bobby Lopez +Jess Robinson +Joel Berger +Johannes Plunien +Mario Zieschang +Mark Fowler +Matthew Horsfall (alh) +Michael Peters +Michael Peters +Michiel Beijen +Mickey Nasriachi +Mickey Nasriachi +Mickey Nasriachi +Moritz Onken +Nicolas R +Olaf Alders +Olaf Alders +Olaf Alders +Randy Stauner +Renee Baecker +Shawn M Moore +Shawn Sorichetti +Sunny Patel +Sunny Patel +Talina Shrotriya +Talina Shrotriya +Talina Shrotriya Talina06 <--global> +Thomas Sibley +Tim Bunce +Vyacheslav Matyukhin +Zachary Dykstra +lnation +oiami +oiami diff --git a/.perlcriticrc b/.perlcriticrc new file mode 100644 index 000000000..578566da8 --- /dev/null +++ b/.perlcriticrc @@ -0,0 +1,40 @@ +# please alpha sort config items as you add them + +severity = 5 +verbose = 11 +theme = core + +[-ControlStructures::ProhibitPostfixControls] +[-Documentation::RequirePodSections] +[-InputOutput::ProhibitInteractiveTest] +[-Modules::RequireVersionVar] +[-RegularExpressions::RequireDotMatchAnything] +[-RegularExpressions::RequireExtendedFormatting] +[-RegularExpressions::RequireLineBoundaryMatching] +[-Subroutines::ProhibitExplicitReturnUndef] +[-TestingAndDebugging::ProhibitNoStrict] +[-ValuesAndExpressions::ProhibitNoisyQuotes] +[-Variables::ProhibitPunctuationVars] + +# doesn't understand signatures +[-Subroutines::ProhibitSubroutinePrototypes] + +[CodeLayout::RequireTrailingCommas] +severity = 4 + +[TestingAndDebugging::RequireUseStrict] +equivalent_modules = MetaCPAN::Moose Mojo::Base Test::Routine + +[TestingAndDebugging::RequireUseWarnings] +equivalent_modules = MetaCPAN::Moose Mojo::Base Test::Routine + +[ValuesAndExpressions::ProhibitEmptyQuotes] +severity = 4 + +[ValuesAndExpressions::ProhibitInterpolationOfLiterals] +allow_if_string_contains_single_quote = 1 +allow = qq{} qq[] +severity = 4 + +[ValuesAndExpressions::ProhibitNoisyQuotes] +severity = 4 diff --git a/.perltidyrc b/.perltidyrc new file mode 100644 index 000000000..ab8fed578 --- /dev/null +++ b/.perltidyrc @@ -0,0 +1,17 @@ +--maximum-line-length=78 +--indent-columns=4 +--continuation-indentation=4 +--standard-error-output +--vertical-tightness=2 +--closing-token-indentation=0 +--paren-tightness=1 +--brace-tightness=1 +--square-bracket-tightness=1 +--block-brace-tightness=1 +--nospace-for-semicolon +--nooutdent-long-quotes +--want-break-before="% + - * / x != == >= <= =~ !~ < > | & = **= += *= &= <<= &&= -= /= |= >>= ||= //= .= %= ^= x=" +# Break a line after opening/before closing token. +--vertical-tightness=0 +--vertical-tightness-closing=0 +--weld-nested-containers diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..2948c9f24 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,101 @@ +ARG SLIM_BUILD +ARG MAYBE_BASE_BUILD=${SLIM_BUILD:+server-base-slim} +ARG BASE_BUILD=${MAYBE_BASE_BUILD:-server-base} + +################### Web Server Base +FROM metacpan/metacpan-base:main-20250531-090128 AS server-base +FROM metacpan/metacpan-base:main-20250531-090129-slim AS server-base-slim + +################### CPAN Prereqs +FROM server-base AS build-cpan-prereqs +SHELL [ "/bin/bash", "-euo", "pipefail", "-c" ] + +WORKDIR /app/ + +COPY cpanfile cpanfile.snapshot ./ +RUN \ + --mount=type=cache,target=/root/.perl-cpm,sharing=private \ +< + Copyright (C) 19yy + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 1, or (at your option) + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) 19xx name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the +appropriate parts of the General Public License. Of course, the +commands you use may be called something other than `show w' and `show +c'; they could even be mouse-clicks or menu items--whatever suits your +program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + program `Gnomovision' (a program to direct compilers to make passes + at assemblers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +That's all there is to it! + + +--- The Artistic License 1.0 --- + +This software is Copyright (c) 2010 by Olaf Alders. + +This is free software, licensed under: + + The Artistic License 1.0 + +The Artistic License + +Preamble + +The intent of this document is to state the conditions under which a Package +may be copied, such that the Copyright Holder maintains some semblance of +artistic control over the development of the package, while giving the users of +the package the right to use and distribute the Package in a more-or-less +customary fashion, plus the right to make reasonable modifications. + +Definitions: + + - "Package" refers to the collection of files distributed by the Copyright + Holder, and derivatives of that collection of files created through + textual modification. + - "Standard Version" refers to such a Package if it has not been modified, + or has been modified in accordance with the wishes of the Copyright + Holder. + - "Copyright Holder" is whoever is named in the copyright or copyrights for + the package. + - "You" is you, if you're thinking about copying or distributing this Package. + - "Reasonable copying fee" is whatever you can justify on the basis of media + cost, duplication charges, time of people involved, and so on. (You will + not be required to justify it to the Copyright Holder, but only to the + computing community at large as a market that must bear the fee.) + - "Freely Available" means that no fee is charged for the item itself, though + there may be fees involved in handling the item. It also means that + recipients of the item may redistribute it under the same conditions they + received it. + +1. You may make and give away verbatim copies of the source form of the +Standard Version of this Package without restriction, provided that you +duplicate all of the original copyright notices and associated disclaimers. + +2. You may apply bug fixes, portability fixes and other modifications derived +from the Public Domain or from the Copyright Holder. A Package modified in such +a way shall still be considered the Standard Version. + +3. You may otherwise modify your copy of this Package in any way, provided that +you insert a prominent notice in each changed file stating how and when you +changed that file, and provided that you do at least ONE of the following: + + a) place your modifications in the Public Domain or otherwise make them + Freely Available, such as by posting said modifications to Usenet or an + equivalent medium, or placing the modifications on a major archive site + such as ftp.uu.net, or by allowing the Copyright Holder to include your + modifications in the Standard Version of the Package. + + b) use the modified Package only within your corporation or organization. + + c) rename any non-standard executables so the names do not conflict with + standard executables, which must also be provided, and provide a separate + manual page for each non-standard executable that clearly documents how it + differs from the Standard Version. + + d) make other distribution arrangements with the Copyright Holder. + +4. You may distribute the programs of this Package in object code or executable +form, provided that you do at least ONE of the following: + + a) distribute a Standard Version of the executables and library files, + together with instructions (in the manual page or equivalent) on where to + get the Standard Version. + + b) accompany the distribution with the machine-readable source of the Package + with your modifications. + + c) accompany any non-standard executables with their corresponding Standard + Version executables, giving the non-standard executables non-standard + names, and clearly documenting the differences in manual pages (or + equivalent), together with instructions on where to get the Standard + Version. + + d) make other distribution arrangements with the Copyright Holder. + +5. You may charge a reasonable copying fee for any distribution of this +Package. You may charge any fee you choose for support of this Package. You +may not charge a fee for this Package itself. However, you may distribute this +Package in aggregate with other (possibly commercial) programs as part of a +larger (possibly commercial) software distribution provided that you do not +advertise this Package as a product of your own. + +6. The scripts and library files supplied as input to or produced as output +from the programs of this Package do not automatically fall under the copyright +of this Package, but belong to whomever generated them, and may be sold +commercially, and may be aggregated with this Package. + +7. C or perl subroutines supplied by you and linked into this Package shall not +be considered part of this Package. + +8. The name of the Copyright Holder may not be used to endorse or promote +products derived from this software without specific prior written permission. + +9. THIS PACKAGE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF +MERCHANTIBILITY AND FITNESS FOR A PARTICULAR PURPOSE. + +The End + diff --git a/README.md b/README.md index 28434ced1..eb3f0bdc5 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,149 @@ -A Web Service for the CPAN -========================== +# A Web Service for the CPAN -The CPAN-API project (MetaCPAN) aims to provide a free, open web service which -provides metadata for CPAN modules. +[![CircleCI](https://circleci.com/gh/metacpan/metacpan-api.svg?style=svg)](https://circleci.com/gh/metacpan/metacpan-api) -REST API --------- +MetaCPAN aims to provide a free, open web service which provides metadata for +CPAN modules. -MetaCPAN is based on ElasticSearch, so it provides a RESTful interface as well -as the option to create complex queries. [The -wiki](https://github.com/CPAN-API/cpan-api/wiki/API-docs) provides a good -starting point for REST access to MetaCPAN. +## REST API -Expanding Your Author Info --------------------------- +MetaCPAN is based on Elasticsearch, so it provides a RESTful interface as well +as the option to create complex queries. [The `docs/` +directory](https://github.com/metacpan/metacpan-api/blob/master/docs/API-docs.md) +provides a good starting point for REST access to MetaCPAN. + +## Expanding Your Author Info MetaCPAN allows authors to add custom metadata about themselves to the index. -You are encouraged to add your own custom fields. Have a look at [this short -article](http://blogs.perl.org/users/olaf_alders/2010/12/expanding-your-author-info-in-the-metacpan.html) -on how to expand your author info: +[Log in to MetaCPAN](https://metacpan.org/account/profile) to add more +information about yourself. + +## Installing Your Own MetaCPAN + +If you want to run MetaCPAN locally, we encourage you to start with +[metacpan-docker](https://github.com/metacpan/metacpan-docker). However, you +may still find some info here: + +## Troubleshooting Elasticsearch + +You can restart Elasticsearch (ES) manually if you need to troubleshoot. + +```sh +sudo service elasticsearch restart +``` + +If you are unable to access [[http://localhost:9200]] (give it a few seconds) +you should kill the Elasticsearch process and run it in foreground to see the +debug output + +```sh +sudo service elasticsearch stop +cd /opt/elasticsearch +sudo bin/elasticsearch -f +``` + +If you get a "Can't start up: not enough memory" error when trying to start +Elasticsearch, you likely need to update your JRE. On Ubuntu: + +```sh +# fixes "not enough memory" errors +sudo apt-get install openjdk-6-jre +``` + +(Note: If you intend to try indexing a full MiniCPAN, you may find that +Elasticsearch wants to use more open filehandles than your system allows by +default. [This script](https://gist.github.com/3230962) can be used to start ES +with the appropriate ulimit adjustment). + +## Run the test suite + +The test suite accesses Elasticsearch on port 9900. The developer VM should +have a dedicated test instance running in the background already, but if you +want to run it manually: + +```sh +cd /opt/elasticsearch +sudo bin/elasticsearch -f -Des.http.port=9900 -Des.cluster.name=testing +``` + +Then run the test suite: + +```sh +cd /home/metacpan/metacpan-api +./bin/prove t +``` + +The test suite has to pass all tests. + +## Create the ElasticSearch Index + +```sh +./bin/run bin/metacpan mapping --delete +``` + +`--delete` will drop all indices first to clear the index from test data. + +## Begin Indexing Your Modules + +```sh +./bin/run bin/metacpan release /path/to/cpan/authors/id/ +``` + +You should note that you can index either your CPAN mirror or a minicpan +mirror. You can even index just parts of a mirror: + +```sh +./bin/run bin/metacpan release /path/to/cpan/authors/id/{A,B} +``` + +## Tag the Latest Releases + +```sh +./bin/run bin/metacpan latest --cpan /path/to/cpan/ +``` + +## Index Author Data + +```sh +./bin/run bin/metacpan author --cpan /path/to/cpan/ +``` + +Note that minicpan doesn't provide the 00whois.xml file which is used to +generate the index; you will have to download it manually (it is in the +authors/ directory) in order to index authors. + +```bash +wget -O /path/to/cpan/authors/00whois.xml cpan.cpantesters.org/authors/00whois.xml +``` + +It also doesn't include author.json files, so that data will also be missing +unless you get it from somewhere else. + +## Set Up Proxy in Front of ElasticSearch + +Start API server on port 5000 + +```sh +./bin/run plackup -p 5000 -r +``` + +This will start a single-threaded test server. If you need extra performance, +use `Starman` instead. + +## Notes -Contributing: -------------- +For a full list of options: -This project currently has very few committers. If you'd like to get involved, -please join our mailing list (see below) and let us know what you'd like to -start working on. +```sh +./bin/run bin/metacpan release --help +``` -http://search.metacpan.org --------------------------- +## Contributing -[http://search.metacpan.org](http://search.metacpan.org) is a pure JavaScript -CPAN search engine, which is built on top of MetaCPAN. +If you'd like to get involved, find us at #metacpan on irc.perl.org or open an +issue on GitHub and let us know what you'd like to start working on. -Mailing List ------------- +## IRC -Our mailing list is open to all: -[http://groups.google.com/group/cpan-api](http://groups.google.com/group/cpan-api) +You can find us at #metacpan on irc.perl.org +Access it via [web interface](https://chat.mibbit.com/?channel=%23metacpan&server=irc.perl.org). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..0d2aab4de --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| `master` branch | :white_check_mark: | + +## Reporting a Vulnerability + +Please report all vulnerabilities by sending an email to noc@metacpan.org diff --git a/app.psgi b/app.psgi new file mode 100644 index 000000000..184c2428c --- /dev/null +++ b/app.psgi @@ -0,0 +1,100 @@ +use strict; +use warnings; +use File::Basename (); +my $root_dir; + +BEGIN { + $root_dir = File::Basename::dirname(__FILE__); +} +use lib "$root_dir/lib"; + +use Config::ZOMG (); +use File::Path (); +use File::Spec (); +use Log::Log4perl (); +use Path::Tiny qw( path ); +use Plack::App::File (); +use Plack::App::Directory (); +use Plack::App::URLMap (); +use Plack::Util (); + +my $dev_mode; +my $config; + +BEGIN { + $dev_mode = $ENV{PLACK_ENV} && $ENV{PLACK_ENV} eq 'development'; + $config = Config::ZOMG->open( + name => 'MetaCPAN::Server', + path => $root_dir, + ); + + if ($dev_mode) { + $ENV{METACPAN_SERVER_DEBUG} = 1; + if ( !$ENV{EMAIL_SENDER_TRANSPORT} ) { + $ENV{EMAIL_SENDER_TRANSPORT} = 'Maildir'; + File::Path::mkpath( $ENV{EMAIL_SENDER_TRANSPORT_dir} + = "$root_dir/var/tmp/mail" ); + } + } + + my $log4perl_config + = File::Spec->rel2abs( $config->{log4perl_file} || 'log4perl.conf', + $root_dir ); + Log::Log4perl::init($log4perl_config); + + package MetaCPAN::Server::WarnHandler; ## no critic (Modules::RequireFilenameMatchesPackage) + Log::Log4perl->wrapper_register(__PACKAGE__); + my $logger = Log::Log4perl->get_logger; + $SIG{__WARN__} = sub { $logger->warn(@_) }; +} + +use MetaCPAN::Server (); + +STDERR->autoflush; + +# prevent output buffering when in Docker containers (e.g. in docker-compose) +if ( -e "/.dockerenv" and MetaCPAN::Server->log->isa('Catalyst::Log') ) { + STDOUT->autoflush; +} + +sub _add_headers { + my ( $app, $add_headers ) = @_; + sub { + Plack::Util::response_cb( + $app->(@_), + sub { + my $res = shift; + my ( $status, $headers ) = @$res; + if ( $status >= 200 && $status < 300 ) { + push @$headers, @$add_headers; + } + return $res; + } + ); + }; +} + +my $static + = Plack::App::Directory->new( + { root => path( $root_dir, 'root', 'static' ) } )->to_app; + +my $urlmap = Plack::App::URLMap->new; +$urlmap->map( + '/favicon.ico' => _add_headers( + Plack::App::File->new( + file => path( $root_dir, 'root', 'static', 'favicon.ico' ) + )->to_app, + [ + 'Cache-Control' => 'public, max-age=' . ( 60 * 60 * 24 ), + 'Surrogate-Control' => 'max-age=' . ( 60 * 60 * 24 * 365 ), + 'Surrogate-Key' => 'static', + ], + ) +); +$urlmap->map( '/static' => $static ); +if ( $ENV{PLACK_ENV} && $ENV{PLACK_ENV} eq 'development' ) { + $urlmap->map( '/v1' => MetaCPAN::Server->app ); +} +$urlmap->map( '/' => MetaCPAN::Server->app ); + +return $urlmap->to_app; diff --git a/bin/api.pl b/bin/api.pl new file mode 100755 index 000000000..40ffdc72e --- /dev/null +++ b/bin/api.pl @@ -0,0 +1,40 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +=head2 DESCRIPTION + +This is the API web server interface. + + # On vagrant VM + ./bin/run morbo bin/api.pl + +To run the api web server, run the following on one of the servers: + + # Run the daemon on a local port (tunnel to display on your browser) + ./bin/run bin/api.pl daemon + +Start Minion worker on vagrant: + + cd /home/vagrant/metacpan-api + ./bin/run bin/api.pl minion worker + +Get status on jobs and workers. + +On production: + + sh /home/metacpan/bin/metacpan-api-carton-exec bin/api.pl minion job -s + +On vagrant: + + cd /home/vagrant/metacpan-api + ./bin/run bin/api.pl minion job -s + +=cut + +use lib 'lib'; + +# Start command line interface for application +require Mojolicious::Commands; +Mojolicious::Commands->start_app('MetaCPAN::API'); diff --git a/bin/check_json.pl b/bin/check_json.pl deleted file mode 100644 index 866c13955..000000000 --- a/bin/check_json.pl +++ /dev/null @@ -1,15 +0,0 @@ -use 5.010; - -use Data::Dumper; -use JSON::XS; - -foreach my $file ( @ARGV ) { - say "Processing $file"; - eval { - my $hash = decode_json( do { local( @ARGV, $/ ) = $file; <> } ); - print Dumper( $hash ); - }; - - if( $@ ) { say "\terror in $file: $@" } - } - diff --git a/bin/cpantesters_api_file_for_testing b/bin/cpantesters_api_file_for_testing new file mode 100755 index 000000000..f7eb90d22 --- /dev/null +++ b/bin/cpantesters_api_file_for_testing @@ -0,0 +1,59 @@ +#!/bin/bash + +cd `dirname "$0"` +cd .. + +url=http://api.cpantesters.org/v3/release +in=t/var/tmp/cpantesters-release-api.json +out=t/var/cpantesters-release-api-fake.json + +download_original () { + test -s "$in" || wget -O "$in" "$url" +} + +append_json () { + perl -MJSON::PP -e' + $file = shift; + $all = -e $file ? decode_json( + do { local $/; open $fh, "<", $file; <$fh> } + ) : []; + $add = decode_json( join "", ); + push @$all, $add; + open $fh, ">", $file; + print { $fh } encode_json( $all ) ' $out +} + +collect_dist () { + local dist="$1" version="$2" + jq '.[] | select( .dist == $dist and .version == $version )' \ + --arg dist "$dist" --arg version "$version" $in \ + | append_json +} + +fake_dist () { + echo "{ \"dist\": \"$1\", \"version\": \"$2\", \"pass\": $3, \"fail\": $4, \ + \"na\": $5, \"unknown\": $6 }" | append_json; +} + +populate_file () { + rm -f "$out" + + # Get test cases from real data. + collect_dist 'Devel-GoFaster' '0.000' + collect_dist 'P' '1.0.20' + collect_dist 'IPsonar' '0.29' + collect_dist 'weblint' '++-1.15' + collect_dist 'WWW-Tumblr' '' + + # Add records for our fake dists. + fake_dist 'Some' '1.00-TRIAL' 4 3 2 1 +} + +if [ !-x $( which jq ) ]; then + echo "ERROR: jq(1) required for this script" + exit 1 +fi + +download_original +populate_file + diff --git a/bin/cpantesters_mini_db_for_testing b/bin/cpantesters_mini_db_for_testing new file mode 100755 index 000000000..8b279c3d0 --- /dev/null +++ b/bin/cpantesters_mini_db_for_testing @@ -0,0 +1,63 @@ +#!/bin/bash + +cd `dirname "$0"` +cd .. + +url=http://devel.cpantesters.org/release/release.db.bz2 +in=t/var/tmp/cpantesters-release.db +out=t/var/cpantesters-release-fake.db +table=release + +download_original () { + test -s "$in" || \ + wget -O "$in.bz2" "$url" + test -f "$in.bz2" && \ + bunzip2 "$in.bz2" + + rm -f "$out" "$out.bz2" +} + +finish () { + # Compress the db like cpantesters does. + bzip2 "$out" +} + +sqlout () { sqlite3 "$out"; } +sql () { + sqlite3 "$in" | sqlout +} + +dist_version () { + local dist="$1" version="$2" +cat <new( $current_dir, '..', 'conf' ); -my @files = File::Find::Rule->file->name( '*.json' )->in( $author_dir ); - -my %fields; - -foreach my $file ( @files ) { - print "Processing $file"; - my $hash; - - eval { - $hash = decode_json( do { local( @ARGV, $/ ) = $file; <> } ); - } or print "\terror in $file: $@"; - - while ( my ($author, $info) = each %{$hash} ) { - my @local_fields = keys %{$info}; - @fields{@local_fields} = @local_fields; - } -} - -print $_ for sort keys %fields; \ No newline at end of file diff --git a/bin/install-precious b/bin/install-precious new file mode 100755 index 000000000..0d712a470 --- /dev/null +++ b/bin/install-precious @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# This is for installing precious and other 3rd party libs needed for linting +# in CI + +set -euo pipefail + +if [ -z "${1:-}" ]; then + echo "usage: ./bin/install-precious /path/to/bin/dir" + exit 1 +fi + +TARGET=$1 +export TARGET + +TARGET=$1 +export TARGET + +curl --silent --location \ + https://raw.githubusercontent.com/houseabsolute/ubi/master/bootstrap/bootstrap-ubi.sh | + sh + +ubi --project houseabsolute/omegasort --in "$TARGET" +ubi --project houseabsolute/precious --in "$TARGET" diff --git a/bin/metacpan b/bin/metacpan new file mode 100755 index 000000000..e57c34fdb --- /dev/null +++ b/bin/metacpan @@ -0,0 +1,23 @@ +#!/usr/bin/env perl + +=head1 SYNOPSIS + + # sample usage + + bin/metacpan release /path/to/cpan/authors/id/ + bin/metacpan release /path/to/cpan/authors/id/{A,B} + bin/metacpan release /path/to/cpan/authors/id/D/DO/DOY/Try-Tiny-0.09.tar.gz + bin/metacpan latest + bin/metacpan server --cpan /path/to/cpan/ + +=cut + +use strict; +use warnings; +use FindBin (); +use lib "$FindBin::RealBin/../lib"; +use MetaCPAN::Script::Runner (); + +MetaCPAN::Script::Runner->run; + +exit $MetaCPAN::Script::Runner::EXIT_CODE; diff --git a/bin/mirror_cpan_for_developers.pl b/bin/mirror_cpan_for_developers.pl new file mode 100755 index 000000000..7a8d4de10 --- /dev/null +++ b/bin/mirror_cpan_for_developers.pl @@ -0,0 +1,15 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +# This script is only needed if you are developing metacpan, +# on the live servers we use File::Rsync::Mirror::Recent +# https://github.com/metacpan/metacpan-puppet/tree/master/modules/rrrclient + +use CPAN::Mini; + +CPAN::Mini->update_mirror( + remote => 'http://www.cpan.org/', + local => "/home/metacpan/CPAN", + log_level => 'warn', +); diff --git a/bin/munin/monitor_minion_queue.pl b/bin/munin/monitor_minion_queue.pl new file mode 100755 index 000000000..91a83e6ff --- /dev/null +++ b/bin/munin/monitor_minion_queue.pl @@ -0,0 +1,58 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +# Munin runs this as metacpan user, but with root's env +# it's only for production so path is hard coded + +my $config_mode = 0; +$config_mode = 1 if $ARGV[0] && $ARGV[0] eq 'config'; + +if ($config_mode) { + + # Dump this (though we supported dynamic below) so it's faster + print <<'EOF'; +graph_title Minion Queue stats +graph_vlabel count +graph_category metacpan_api +graph_info What's happening in the Minion queue +workers_inactive.label Inactive workers +workers_active.label Active workers +jobs_inactive.label Inactive jobs +jobs_active.label Active jobs +jobs_failed.label Failed jobs +jobs_finished.label Finished jobs +EOF + + exit; +} + +# Get the stats +my $stats_report + = `/home/metacpan/bin/metacpan-api-carton-exec bin/queue.pl minion job -s`; + +my @lines = split( "\n", $stats_report ); + +for my $line (@lines) { + my ( $label, $num ) = split ':', $line; + + $num =~ s/\D//g; + + my $key = lc($label); # Was 'Inactive jobs' + + # Swap type and status around so idle_jobs becomes jobs_idle + $key =~ s/(\w+)\s+(\w+)/$2_$1/g; + + if ($config_mode) { + + # config + print "${key}.label $label\n"; + + } + else { + # results + print "${key}.value $num\n" if $num; + } + +} diff --git a/bin/queue.pl b/bin/queue.pl new file mode 120000 index 000000000..5474dbc6e --- /dev/null +++ b/bin/queue.pl @@ -0,0 +1 @@ +api.pl \ No newline at end of file diff --git a/bin/run b/bin/run new file mode 100755 index 000000000..3c4ded42a --- /dev/null +++ b/bin/run @@ -0,0 +1,10 @@ +#!/bin/sh + +# Use the puppet-installed wrapper to set up the env properly. +wrapper=$HOME/bin/metacpan-api-carton +test -x $wrapper && \ +exec $wrapper exec -- "$@" + +# If the wrapper doesn't exist, just try it with plain carton. +cd "`dirname "$0"`"/.. +exec carton exec -- "$@" diff --git a/bin/wait-for-open b/bin/wait-for-open new file mode 100755 index 000000000..a9729260e --- /dev/null +++ b/bin/wait-for-open @@ -0,0 +1,16 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +my $server = shift; + +my $timeout = 30; +while ( $timeout-- ) { + if ( !system "curl -s '$server' 2>/dev/null 1>&2" ) { + exit 0; + } + sleep 1; +} + +print STDERR "Timed out starting elasticsearch!\n"; +exit 1; diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..be10c0793 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,16 @@ +--- +comment: + layout: 'diff, files' + behavior: default + require_changes: true # if true: only post the comment if coverage changes + require_base: false # [true :: must have a base report to post] + require_head: true # [true :: must have a head report to post] + hide_project_coverage: false # [true :: only show coverage on the git diff] +coverage: + status: + patch: + default: + threshold: 1% + project: + default: + threshold: 1% diff --git a/conf/README.md b/conf/README.md deleted file mode 100644 index 3c4ac5086..000000000 --- a/conf/README.md +++ /dev/null @@ -1,22 +0,0 @@ -####conf/author.json - -conf/author.json is now a sample file. Please use this as a reference for -fields you may want to add to your author.json file - -Author files are now in a directory structure which is the same as your CPAN -author directory (thanks to BDFOY) For example, you'll find BDFOY in -conf/authors/B/BD/BDFOY/author.json - -conf/author.json is a mashup of fields added by different authors. If you add -a new field to your own author.json file, please also add it to -conf/author.json so it's easier for everyone to find. Use ARRAYs where you -feel it's appropriate. - -For a current list of all fields used by other authors, have a look at -conf/USEDFIELDS.txt You can also update this file: - -perl bin/get_fields.pl > conf/USEDFIELDS.txt - -Once you've completed your own author file, please check your syntax. :) - -perl bin/check_json.pl conf/authors/B/BD/BDFOY/author.json diff --git a/conf/USEDFIELDS.txt b/conf/USEDFIELDS.txt deleted file mode 100644 index e27f20b34..000000000 --- a/conf/USEDFIELDS.txt +++ /dev/null @@ -1,26 +0,0 @@ -accepts_donations -amazon_author_profile -blog_feed -blog_url -books -cats -city -country -delicious_username -email -facebook_public_profile -github_username -irc_nick -linkedin_public_profile -openid -oreilly_author_profile -paypal_address -perlmongers -perlmongers_url -perlmonks_username -region -slideshare_url -slideshare_username -stackoverflow_public_profile -twitter_username -website diff --git a/conf/author.json b/conf/author.json deleted file mode 100644 index 8e978f671..000000000 --- a/conf/author.json +++ /dev/null @@ -1,51 +0,0 @@ -{ -"BDFOY": { - "accepts_donations": "1", - "paypal_address": "brian.d.foy@gmail.com", - "country": "US", - "region": "IL", - "city": "Chicago", - "website": [ - "http://www.pair.com/comdog", - "http://about.me/brian_d_foy" - ], - "email": [ - "brian.d.foy@gmail.com", - "bdfoy@cpan.org" - ], - "delicious_username": "manske", - "facebook_public_profile": "http://www.facebook.com/rbo.openserv.org", - "github_username": "briandfoy", - "linkedin_public_profile": "http://www.linkedin.com/in/briandfoy", - "openid": "http://sartak.org", - "stackoverflow_public_profile": "http://stackoverflow.com/users/8817/brian-d-foy", - "perlmongers" : "Frankfurt.pm", - "perlmongers_url" : "http://frankfurt.perlmongers.de", - "perlmonks_username": "brian_d_foy", - "twitter_username": "briandfoy_perl", - "slideshare_url": "http://www.slideshare.net/brian_d_foy/", - "slideshare_username" : "reneebperl", - "amazon_author_profile": "http://www.amazon.com/brian-d-foy/e/B002MRC39U", - "oreilly_author_profile": "http://www.oreillynet.com/pub/au/1071", - "books": [ - "0596527241", - "0321496949", - "0596102062", - "0596009968", - "0596520107", - "0596101058" - ], - "blog_url": [ - "http://blogs.perl.org/users/brian_d_foy/", - "http://www.effectiveperlprogramming.com/", - "http://use.perl.org/~brian_d_foy/journal/" - ], - "blog_feed": [ - "http://blogs.perl.org/users/brian_d_foy/atom.xml", - "http://www.effectiveperlprogramming.com/feed", - "http://use.perl.org/~brian_d_foy/journal/rss" - ], - "cats": [ "Buster", "Mimi" ] - } -} - diff --git a/conf/authors/B/BD/BDFOY/author.json b/conf/authors/B/BD/BDFOY/author.json deleted file mode 100644 index e30e43328..000000000 --- a/conf/authors/B/BD/BDFOY/author.json +++ /dev/null @@ -1,44 +0,0 @@ -{ -"BDFOY": { - "accepts_donations": "1", - "paypal_address": "brian.d.foy@gmail.com", - "country": "US", - "region": "IL", - "city": "Chicago", - "website": [ - "http://www.pair.com/comdog", - "http://about.me/brian_d_foy" - ], - "email": [ - "brian.d.foy@gmail.com", - "bdfoy@cpan.org" - ], - "github_username": "briandfoy", - "linkedin_public_profile": "http://www.linkedin.com/in/briandfoy", - "stackoverflow_public_profile": "http://stackoverflow.com/users/8817/brian-d-foy", - "perlmonks_username": "brian_d_foy", - "twitter_username": "briandfoy_perl", - "slideshare_url": "http://www.slideshare.net/brian_d_foy/", - "amazon_author_profile": "http://www.amazon.com/brian-d-foy/e/B002MRC39U", - "oreilly_author_profile": "http://www.oreillynet.com/pub/au/1071", - "books": [ - "0596527241", - "0321496949", - "0596102062", - "0596009968", - "0596520107", - "0596101058" - ], - "blog_url": [ - "http://blogs.perl.org/users/brian_d_foy/", - "http://www.effectiveperlprogramming.com/", - "http://use.perl.org/~brian_d_foy/journal/" - ], - "blog_feed": [ - "http://blogs.perl.org/users/brian_d_foy/atom.xml", - "http://www.effectiveperlprogramming.com/feed", - "http://use.perl.org/~brian_d_foy/journal/rss" - ], - "cats": [ "Buster", "Mimi" ] - } -} diff --git a/conf/authors/D/DM/DMAKI/author.json b/conf/authors/D/DM/DMAKI/author.json deleted file mode 100644 index 8b18d52ab..000000000 --- a/conf/authors/D/DM/DMAKI/author.json +++ /dev/null @@ -1,9 +0,0 @@ -{ "DMAKI": { - "website": "http://mt.endeworks.jp/d-6/", - "irc_nick": "lestrrat", - "github_username": "lestrrat", - "twitter_username": "lestrrat", - "blog_url": "http://mt.endeworks.jp/d-6/", - "blog_feed": "http://mt.endeworks.jp/d-6/atom.xml" - } -} diff --git a/conf/authors/F/FR/FREW/author.json b/conf/authors/F/FR/FREW/author.json deleted file mode 100644 index 88473bc91..000000000 --- a/conf/authors/F/FR/FREW/author.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "FREW": { - "blog_feed": "http://feeds.feedburner.com/AFoolishManifesto", - "blog_url": "http://blog.afoolishmanifesto.com", - "github_username": "frioux", - "irc_nick": "frew", - "perlmonks_username": "frew", - "stackoverflow_public_profile": "http://stackoverflow.com/users/12448/frew", - "twitter_username": "frioux", - "website": "http://afoolishmanifesto.com" - } -} diff --git a/conf/authors/H/HM/HMA/author.json b/conf/authors/H/HM/HMA/author.json deleted file mode 100644 index 8d31919cf..000000000 --- a/conf/authors/H/HM/HMA/author.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "HMA": { - "github_username": "hma", - "perlmonks_username": "hma", - "openid": "https://hma.myopenid.com/", - "delicious_username": "manske", - "facebook_public_profile": "http://www.facebook.com/people/Henning-Manske/606746249", - "slideshare_username": "manske", - "country": "DE", - "region": "DE-SH", - "city": "Kiel" - } -} diff --git a/conf/authors/I/IO/IONCACHE/author.json b/conf/authors/I/IO/IONCACHE/author.json deleted file mode 100644 index 866c05f4e..000000000 --- a/conf/authors/I/IO/IONCACHE/author.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "IONCACHE": { - "github_username": "ioncache", - "linkedin_public_profile": "http://ca.linkedin.com/in/mjubenville", - "perlmonks_username": "ioncache", - "stackoverflow_public_profile": "http://stackoverflow.com/users/525975/ioncache", - "irc_nick": "ioncache", - "twitter_username": "ioncache" - } -} diff --git a/conf/authors/M/MM/MMUSGROVE/author.json b/conf/authors/M/MM/MMUSGROVE/author.json deleted file mode 100644 index 87f3ee53d..000000000 --- a/conf/authors/M/MM/MMUSGROVE/author.json +++ /dev/null @@ -1,21 +0,0 @@ -{ -"MMUSGROVE": { - "accepts_donations": "1", - "paypal_address": "mr.muskrat@gmail.com", - "country": "US", - "region": "TX", - "city": "Arlington", - "email": [ - "mr.muskrat@gmail.com", - "mmusgrove@cpan.org" - ], - "github_username": "mrmuskrat", - "linkedin_public_profile": "http://www.linkedin.com/in/matthewmusgrove", - "stackoverflow_public_profile": "http://stackoverflow.com/users/10576/mr-muskrat", - "perlmonks_username": "Mr. Muskrat", - "twitter_username": "mrmuskrat", - "blog_url": "http://blogs.perl.org/users/mr_muskrat/", - "dogs": [ "Brave Sir Robin", "Ashley" ] - } -} - diff --git a/conf/authors/O/OA/OALDERS/author.json b/conf/authors/O/OA/OALDERS/author.json deleted file mode 100644 index 22b8a59e0..000000000 --- a/conf/authors/O/OA/OALDERS/author.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "OALDERS": { - "website": "http://www.wundersolutions.com", - "irc_nick": "oalders", - "github_username": "oalders", - "linkedin_public_profile": "http://ca.linkedin.com/in/olafalders", - "stackoverflow_public_profile": "http://stackoverflow.com/users/406224/oalders", - "perlmonks_username": "oalders", - "twitter_username": "wundercounter", - "blog_url": "http://blogs.perl.org/users/olaf_alders/", - "blog_feed": "http://blogs.perl.org/users/olaf_alders/atom.xml" - } -} diff --git a/conf/authors/P/PD/PDONELAN/author.json b/conf/authors/P/PD/PDONELAN/author.json deleted file mode 100644 index f4e44e624..000000000 --- a/conf/authors/P/PD/PDONELAN/author.json +++ /dev/null @@ -1,23 +0,0 @@ -{ -"PDONELAN": { - "country": "US", - "region": "NY", - "city": "New York", - "website": [ - "http://patspam.com" - ], - "email": [ - "pdonelan@cpan.org" - ], - "github_username": "pdonelan", - "perlmongers" : "NY.pm", - "twitter_username": "patspam", - "blog_url": [ - "http://blog.patspam.com" - ], - "blog_feed": [ - "http://blog.patspam.com/feed/atom" - ] - } -} - diff --git a/conf/authors/R/RB/RBO/author.json b/conf/authors/R/RB/RBO/author.json deleted file mode 100644 index c891b7902..000000000 --- a/conf/authors/R/RB/RBO/author.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "RBO": { - "blog_url": "http://openserv.org/blog/", - "blog_feed": "http://openserv.org/blog/atom.xml", - "facebook_public_profile": "http://www.facebook.com/rbo.openserv.org", - "irc_nick": "rbo", - "github_username": "rbo" - } -} diff --git a/conf/authors/R/RE/RENEEB/author.json b/conf/authors/R/RE/RENEEB/author.json deleted file mode 100644 index c763cc0d4..000000000 --- a/conf/authors/R/RE/RENEEB/author.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "RENEEB": { - "blog_feed": "http://reneeb-perlblog.blogspot.com/feeds/posts/default", - "blog_url": "http://reneeb-perlblog.blogspot.com", - "github_username": "reneeb", - "website": "http://perl-magazin.de", - "linkedin_public_profile": "http://de.linkedin.com/in/reneebaecker", - "perlmongers" : "Frankfurt.pm", - "perlmongers_url" : "http://frankfurt.perlmongers.de", - "twitter_username" : "reneeb_perl", - "slideshare_username" : "reneebperl", - "irc_nick" : "reneeb", - "perlmonks_username" : "reneeb", - "country": "Germany" - } -} diff --git a/conf/authors/R/RW/RWSTAUNER/author.json b/conf/authors/R/RW/RWSTAUNER/author.json deleted file mode 100644 index df7b3053c..000000000 --- a/conf/authors/R/RW/RWSTAUNER/author.json +++ /dev/null @@ -1,20 +0,0 @@ -{ -"RWSTAUNER": { - "accepts_donations": "1", - "paypal_address": "randy@magnificent-tears.com", - "country": "US", - "region": "AZ", - "city": "Mesa", - "website": [ - "http://www.magnificent-tears.com" - ], - "email": [ - "rwstauner@cpan.org" - ], - "github_username": "magnificent-tears", - "linkedin_public_profile": "http://www.linkedin.com/in/randallstauner", - "stackoverflow_public_profile": "http://stackoverflow.com/users/452987/randy-stauner", - "perlmonks_username": "rwstauner", - "twitter_username": "magnificentears" - } -} diff --git a/conf/authors/S/SA/SARTAK/author.json b/conf/authors/S/SA/SARTAK/author.json deleted file mode 100644 index 87562b0d3..000000000 --- a/conf/authors/S/SA/SARTAK/author.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "SARTAK": { - "blog_feed": "http://blog.sartak.org/feeds/posts/default", - "blog_url": "http://blog.sartak.org", - "github_username": "sartak", - "irc_nick": "sartak", - "linkedin_public_profile": "http://www.linkedin.com/in/sartak", - "openid": "http://sartak.org", - "perlmonks_username": "sartak", - "stackoverflow_public_profile": "http://stackoverflow.com/users/290913/sartak", - "twitter_username": "sartak", - "website": "http://sartak.org" - } -} diff --git a/conf/authors/X/XS/XSAWYERX/author.json b/conf/authors/X/XS/XSAWYERX/author.json deleted file mode 100644 index b4c4ac0e6..000000000 --- a/conf/authors/X/XS/XSAWYERX/author.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "XSAWYERX": { - "irc_nick": "xsawyerx", - "github_username": "xsawyerx", - "blog_url": "http://blogs.perl.org/users/sawyer_x/", - "blog_feed": "http://blogs.perl.org/users/sawyer_x/atom.xml" - } -} diff --git a/cpanfile b/cpanfile new file mode 100644 index 000000000..dd1d4513f --- /dev/null +++ b/cpanfile @@ -0,0 +1,166 @@ +use strict; +use warnings; + +requires 'perl', '5.010'; + +requires 'Archive::Any', '0.0946'; +requires 'Archive::Tar', '2.40'; +requires 'Authen::SASL', '2.16'; # for Email::Sender::Transport::SMTP +requires 'Catalyst', '5.90128'; +requires 'Catalyst::Action::RenderView', '0.16'; +requires 'Catalyst::Controller::REST', '1.21'; +requires 'Catalyst::Plugin::Authentication'; +requires 'Catalyst::Plugin::Session', '0.43'; +requires 'Catalyst::Plugin::Session::State::Cookie'; +requires 'Catalyst::Plugin::Session::Store'; +requires 'Catalyst::Plugin::Static::Simple'; +requires 'Catalyst::View::JSON', '0.37'; +requires 'CatalystX::Fastly::Role::Response', '0.06'; +requires 'CHI', '0.61'; +requires 'Config::General', '2.63'; +requires 'Config::ZOMG', '1.000000'; +requires 'Const::Fast'; +requires 'CPAN::DistnameInfo', '0.12'; +requires 'Cpanel::JSON::XS', '4.32'; +requires 'CPAN::Meta', '2.150005'; # Avoid issues with List::Util dep under carton install. +requires 'CPAN::Meta::Requirements', '2.140'; +requires 'CPAN::Meta::YAML', '0.018'; +requires 'CPAN::Repository::Perms'; +requires 'Cwd'; +requires 'Data::Dumper'; +requires 'Data::Visitor::Callback'; +requires 'DateTime', '1.54'; +requires 'DateTime::Format::ISO8601'; +requires 'DBD::SQLite', '1.66'; +requires 'DBI', '1.643'; +requires 'Digest::MD5'; +requires 'Digest::SHA'; +requires 'ElasticSearchX::Model', '2.0.1'; +requires 'Email::Sender::Simple'; +requires 'Email::Simple'; +requires 'Email::Valid', '1.203'; +requires 'Encode', '3.17'; +requires 'Encoding::FixLatin'; +requires 'Encoding::FixLatin::XS'; +requires 'EV'; +requires 'Exporter', '5.74'; +requires 'File::Basename'; +requires 'File::Copy'; +requires 'File::Find'; +requires 'File::Find::Rule'; +requires 'File::Find::Rule::Perl'; +requires 'File::Spec'; +requires 'File::Spec::Functions'; +requires 'File::pushd'; +requires 'File::stat'; +requires 'File::Temp'; +requires 'FindBin'; +requires 'Getopt::Long::Descriptive', '0.103'; +requires 'Gravatar::URL'; +requires 'Hash::Merge::Simple'; +requires 'HTML::Entities'; +requires 'HTTP::Request::Common', '6.36'; +requires 'IO::Prompt::Tiny'; +requires 'IO::Uncompress::Bunzip2', '2.106'; +requires 'IO::Zlib'; +requires 'IPC::Run3', '0.048'; +requires 'List::Util', '1.62'; +requires 'Log::Any::Adapter'; +requires 'Log::Any::Adapter::Log4perl'; +requires 'Log::Contextual'; +requires 'Log::Dispatch'; +requires 'Log::Dispatch::Syslog'; +requires 'Log::Log4perl'; +requires 'Log::Log4perl::Appender::ScreenColoredLevels'; +requires 'Log::Log4perl::Catalyst'; +requires 'Log::Log4perl::Layout::JSON'; +requires 'LWP::Protocol::https'; +requires 'LWP::UserAgent', '6.66'; +requires 'MetaCPAN::Moose'; +requires 'MetaCPAN::Pod::HTML' => '0.004000'; +requires 'MetaCPAN::Role', '1.00'; +requires 'MIME::Base64', '3.15'; +requires 'Minion', '9.03'; +requires 'Minion::Backend::SQLite'; +requires 'Module::Load'; +requires 'Module::Metadata', '1.000038'; +requires 'Module::Pluggable'; +requires 'Module::Runtime'; +requires 'Mojolicious::Plugin::MountPSGI', '0.14'; +requires 'Mojolicious::Plugin::OpenAPI'; +requires 'Mojolicious::Plugin::Web::Auth', '0.17'; +requires 'Mojo::Pg', '4.08'; +requires 'Moose', '2.2201'; +requires 'MooseX::Attribute::Deflator', '2.1.5'; +requires 'MooseX::Fastly::Role', '0.02'; +requires 'MooseX::Getopt', '0.71'; +requires 'MooseX::Getopt::Dashes'; +requires 'MooseX::Getopt::OptionTypeMap'; +requires 'MooseX::StrictConstructor'; +requires 'MooseX::Types'; +requires 'MooseX::Types::ElasticSearch', '0.0.4'; +requires 'MooseX::Types::Moose'; +requires 'Mozilla::CA', '20211001'; +requires 'namespace::autoclean'; +requires 'Net::Fastly', '1.12'; +requires 'Net::GitHub::V4'; +requires 'Parse::CPAN::Packages::Fast', '0.09'; +requires 'Parse::PMFile', '0.43'; +requires 'Path::Iterator::Rule', '>=1.011'; +requires 'PAUSE::Permissions', '0.17'; +requires 'PerlIO::gzip'; +requires 'Plack', '1.0048'; +requires 'Plack::App::Directory'; +requires 'Plack::Middleware::ReverseProxy'; +requires 'Plack::Middleware::Session'; +requires 'Plack::Session::Store'; +requires 'Pod::Markdown', '3.300'; +requires 'Pod::Text', '4.14'; +requires 'Ref::Util'; +requires 'Safe', '2.35'; # bug fixes (used by Parse::PMFile) +requires 'Scalar::Util', '1.62'; # Moose +requires 'Search::Elasticsearch' => '8.12'; +requires 'Search::Elasticsearch::Client::2_0' => '6.81'; +requires 'Throwable::Error'; +requires 'Term::Size::Any'; # for Catalyst +requires 'Text::CSV_XS'; +requires 'Try::Tiny', '0.30'; +requires 'Type::Tiny', '2.000001'; +requires 'Types::Path::Tiny'; +requires 'Types::URI'; +requires 'Twitter::API', '1.0006'; +requires 'URI', '5.10'; +requires 'version', '0.9929'; +requires 'XML::XPath'; +requires 'YAML::XS', '0.83'; # Mojolicious::Plugin::OpenAPI YAML loading + +# test requirements +on test => sub { + requires 'CPAN::Faker', '0.011'; + requires 'Devel::Confess'; + requires 'HTTP::Cookies', '6.10'; + requires 'MetaCPAN::Client', '2.029000'; + requires 'Module::Faker', '== 0.017'; + requires 'Module::Faker::Dist', '== 0.017'; + requires 'OrePAN2', '0.48'; + requires 'Test::Deep'; + requires 'Test::Fatal'; + requires 'Test::Harness', '3.44'; # Contains App::Prove + requires 'Test::More', '1.302190'; + requires 'Test::RequiresInternet'; + requires 'Test::Routine', '0.012'; + requires 'Test::Vars', '0.015'; +}; + +# author requirements +on develop => sub { + requires 'App::perlimports'; + requires 'Perl::Critic', '0.140'; + requires 'Perl::Tidy' => '== 20240511'; + requires 'PPI', '1.274'; # Perl::Critic + requires 'PPIx::QuoteLike', '0.022'; # Perl::Critic + requires 'PPIx::Regexp', '0.085'; # Perl::Critic + requires 'String::Format', '1.18'; # Perl::Critic + requires 'Devel::Cover'; + requires 'Devel::Cover::Report::Codecovbash'; +}; diff --git a/cpanfile.forced b/cpanfile.forced new file mode 100644 index 000000000..47023795c --- /dev/null +++ b/cpanfile.forced @@ -0,0 +1,12 @@ +# transitive deps +# Not used directly, but they need to be explicitly listed to ensure they are +# in our cpanfile.snapshot at appropriate versions. Either for older perl +# versions, or unpredictable dynamic deps. These will be installed using a +# different process to ensure they are present in the snapshot, even if they +# would be satisfied by core. +requires 'CPAN::Meta', '2.141520'; +requires 'Devel::PPPort', '3.62'; # for older perls +requires 'ExtUtils::MakeMaker', '7.76'; +requires 'version', '0.9929'; # for older perls +requires 'Module::Signature', '0.90'; +requires 'Pod::Parser', '1.67'; # for newer perls diff --git a/cpanfile.snapshot b/cpanfile.snapshot new file mode 100644 index 000000000..c3272cc2c --- /dev/null +++ b/cpanfile.snapshot @@ -0,0 +1,8353 @@ +# carton snapshot format: version 1.0 +DISTRIBUTIONS + Algorithm-Diff-1.201 + pathname: R/RJ/RJBS/Algorithm-Diff-1.201.tar.gz + provides: + Algorithm::Diff 1.201 + Algorithm::Diff::_impl 1.201 + requirements: + ExtUtils::MakeMaker 0 + Any-URI-Escape-0.01 + pathname: P/PH/PHRED/Any-URI-Escape-0.01.tar.gz + provides: + Any::URI::Escape 0.01 + requirements: + ExtUtils::MakeMaker 0 + URI::Escape 0 + Apache-LogFormat-Compiler-0.36 + pathname: K/KA/KAZEBURO/Apache-LogFormat-Compiler-0.36.tar.gz + provides: + Apache::LogFormat::Compiler 0.36 + requirements: + Module::Build::Tiny 0.035 + POSIX 0 + POSIX::strftime::Compiler 0.30 + Time::Local 0 + perl 5.008001 + App-perlimports-0.000056 + pathname: O/OA/OALDERS/App-perlimports-0.000056.tar.gz + provides: + App::perlimports 0.000056 + App::perlimports::Annotations 0.000056 + App::perlimports::CLI 0.000056 + App::perlimports::Config 0.000056 + App::perlimports::Document 0.000056 + App::perlimports::ExportInspector 0.000056 + App::perlimports::Include 0.000056 + App::perlimports::Role::Logger 0.000056 + App::perlimports::Sandbox 0.000056 + requirements: + Capture::Tiny 0 + Class::Inspector 1.36 + Cpanel::JSON::XS 0 + Data::Dumper 0 + Data::UUID 0 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::XDG 1.01 + Getopt::Long::Descriptive 0 + List::Util 0 + Log::Dispatch 2.70 + Memoize 0 + Module::Runtime 0 + Moo 0 + Moo::Role 0 + MooX::StrictConstructor 0 + PPI 1.276 + PPI::Document 0 + PPIx::Utils::Classification 0 + Path::Iterator::Rule 0 + Path::Tiny 0 + Perl::Tidy 20220613 + Pod::Usage 0 + Ref::Util 0 + Scalar::Util 0 + Sereal::Decoder 0 + Sereal::Encoder 0 + Sub::HandlesVia 0 + Symbol::Get 0.10 + TOML::Tiny 0.16 + Text::Diff 0 + Text::SimpleTable::AutoWidth 0 + Try::Tiny 0 + Types::Standard 0 + feature 0 + perl v5.18.0 + strict 0 + utf8 0 + warnings 0 + Archive-Any-0.0946 + pathname: O/OA/OALDERS/Archive-Any-0.0946.tar.gz + provides: + Archive::Any 0.0946 + Archive::Any::Plugin 0.0946 + Archive::Any::Plugin::Tar 0.0946 + Archive::Any::Plugin::Zip 0.0946 + Archive::Any::Tar 0.0946 + Archive::Any::Zip 0.0946 + requirements: + Archive::Tar 0 + Archive::Zip 0 + Cwd 0 + ExtUtils::MakeMaker 0 + File::MMagic 0 + File::Spec::Functions 0 + MIME::Types 0 + Module::Find 0 + base 0 + perl 5.006 + strict 0 + warnings 0 + Archive-Any-Create-0.03 + pathname: M/MI/MIYAGAWA/Archive-Any-Create-0.03.tar.gz + provides: + Archive::Any::Create 0.03 + Archive::Any::Create::Tar undef + Archive::Any::Create::Zip undef + requirements: + Archive::Tar 0 + Archive::Zip 0 + Exception::Class 0 + ExtUtils::MakeMaker 6.30 + IO::Zlib 0 + UNIVERSAL::require 0 + Archive-Extract-0.88 + pathname: B/BI/BINGOS/Archive-Extract-0.88.tar.gz + provides: + Archive::Extract 0.88 + requirements: + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Path 0 + File::Spec 0.82 + IPC::Cmd 0.64 + Locale::Maketext::Simple 0 + Module::Load::Conditional 0.66 + Params::Check 0.07 + Test::More 0 + if 0 + Archive-Tar-3.04 + pathname: B/BI/BINGOS/Archive-Tar-3.04.tar.gz + provides: + Archive::Tar 3.04 + Archive::Tar::Constant 3.04 + Archive::Tar::File 3.04 + requirements: + Compress::Zlib 2.015 + ExtUtils::MakeMaker 0 + File::Spec 0.82 + IO::Compress::Base 2.015 + IO::Compress::Bzip2 2.015 + IO::Compress::Gzip 2.015 + IO::Zlib 1.01 + Test::Harness 2.26 + Test::More 0 + perl 5.00503 + Archive-Zip-1.68 + pathname: P/PH/PHRED/Archive-Zip-1.68.tar.gz + provides: + Archive::Zip 1.68 + Archive::Zip::Archive 1.68 + Archive::Zip::BufferedFileHandle 1.68 + Archive::Zip::DirectoryMember 1.68 + Archive::Zip::FileMember 1.68 + Archive::Zip::Member 1.68 + Archive::Zip::MemberRead 1.68 + Archive::Zip::MockFileHandle 1.68 + Archive::Zip::NewFileMember 1.68 + Archive::Zip::StringMember 1.68 + Archive::Zip::Tree 1.68 + Archive::Zip::ZipFileMember 1.68 + requirements: + Compress::Raw::Zlib 2.017 + Encode 0 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Copy 0 + File::Find 0 + File::Path 0 + File::Spec 0.80 + File::Temp 0 + IO::File 0 + IO::Handle 0 + IO::Seekable 0 + Time::Local 0 + perl 5.006 + Authen-SASL-2.1800 + pathname: E/EH/EHUELS/Authen-SASL-2.1800.tar.gz + provides: + Authen::SASL 2.1800 + Authen::SASL::CRAM_MD5 2.1800 + Authen::SASL::EXTERNAL 2.1800 + Authen::SASL::Perl 2.1800 + Authen::SASL::Perl::ANONYMOUS 2.1800 + Authen::SASL::Perl::CRAM_MD5 2.1800 + Authen::SASL::Perl::DIGEST_MD5 2.1800 + Authen::SASL::Perl::EXTERNAL 2.1800 + Authen::SASL::Perl::GSSAPI 2.1800 + Authen::SASL::Perl::LOGIN 2.1800 + Authen::SASL::Perl::OAUTHBEARER 2.1800 + Authen::SASL::Perl::PLAIN 2.1800 + Authen::SASL::Perl::XOAUTH2 2.1800 + requirements: + Digest::HMAC_MD5 0 + Digest::MD5 0 + ExtUtils::MakeMaker 0 + perl 5.014000 + B-Hooks-EndOfScope-0.28 + pathname: E/ET/ETHER/B-Hooks-EndOfScope-0.28.tar.gz + provides: + B::Hooks::EndOfScope 0.28 + B::Hooks::EndOfScope::PP 0.28 + B::Hooks::EndOfScope::XS 0.28 + requirements: + ExtUtils::MakeMaker 0 + Hash::Util::FieldHash 0 + Module::Implementation 0.05 + Scalar::Util 0 + Sub::Exporter::Progressive 0.001006 + Text::ParseWords 0 + Tie::Hash 0 + Variable::Magic 0.48 + perl 5.006001 + strict 0 + warnings 0 + B-Hooks-OP-Check-0.22 + pathname: E/ET/ETHER/B-Hooks-OP-Check-0.22.tar.gz + provides: + B::Hooks::OP::Check 0.22 + requirements: + DynaLoader 0 + ExtUtils::Depends 0.302 + ExtUtils::MakeMaker 0 + parent 0 + perl 5.008001 + strict 0 + warnings 0 + B-Keywords-1.27 + pathname: R/RU/RURBAN/B-Keywords-1.27.tar.gz + provides: + B::Keywords 1.27 + requirements: + B 0 + ExtUtils::MakeMaker 0 + CGI-Simple-1.281 + pathname: M/MA/MANWAR/CGI-Simple-1.281.tar.gz + provides: + CGI::Simple 1.281 + CGI::Simple::Cookie 1.281 + CGI::Simple::Standard 1.281 + CGI::Simple::Util 1.281 + requirements: + ExtUtils::MakeMaker 0 + CGI-Struct-1.21 + pathname: F/FU/FULLERMD/CGI-Struct-1.21.tar.gz + provides: + CGI::Struct 1.21 + requirements: + ExtUtils::MakeMaker 0 + Storable 0 + Test::Deep 0 + Test::More 0 + CHI-0.61 + pathname: A/AS/ASB/CHI-0.61.tar.gz + provides: + CHI 0.61 + CHI::CacheObject 0.61 + CHI::Driver 0.61 + CHI::Driver::Base::CacheContainer 0.61 + CHI::Driver::CacheCache 0.61 + CHI::Driver::FastMmap 0.61 + CHI::Driver::File 0.61 + CHI::Driver::Memory 0.61 + CHI::Driver::Metacache 0.61 + CHI::Driver::Null 0.61 + CHI::Driver::RawMemory 0.61 + CHI::Driver::Role::HasSubcaches 0.61 + CHI::Driver::Role::IsSizeAware 0.61 + CHI::Driver::Role::IsSubcache 0.61 + CHI::Stats 0.61 + requirements: + Carp::Assert 0.20 + Class::Load 0 + Data::UUID 0 + Digest::JHash 0 + Digest::MD5 0 + ExtUtils::MakeMaker 0 + File::Spec 0.80 + Hash::MoreUtils 0 + JSON::MaybeXS 1.003003 + List::MoreUtils 0.13 + Log::Any 0.08 + Moo 1.003 + MooX::Types::MooseLike 0.23 + MooX::Types::MooseLike::Base 0 + MooX::Types::MooseLike::Numeric 0 + Storable 0 + String::RewritePrefix 0 + Task::Weaken 0 + Time::Duration 1.06 + Time::Duration::Parse 0.03 + Time::HiRes 1.30 + Try::Tiny 0.05 + CPAN-Checksums-2.14 + pathname: A/AN/ANDK/CPAN-Checksums-2.14.tar.gz + provides: + CPAN::Checksums 2.14 + requirements: + Compress::Bzip2 0 + Compress::Zlib 0 + Data::Compare 0 + Data::Dumper 0 + Digest::MD5 2.36 + Digest::SHA 0 + DirHandle 0 + ExtUtils::MakeMaker 0 + File::Spec 0 + File::Temp 0 + IO::File 1.14 + CPAN-DistnameInfo-0.12 + pathname: G/GB/GBARR/CPAN-DistnameInfo-0.12.tar.gz + provides: + CPAN::DistnameInfo 0.12 + requirements: + ExtUtils::MakeMaker 0 + Test::More 0 + CPAN-Faker-0.012 + pathname: R/RJ/RJBS/CPAN-Faker-0.012.tar.gz + provides: + CPAN::Faker 0.012 + requirements: + CPAN::Checksums 0 + Compress::Zlib 0 + Cwd 0 + Data::Section 0 + ExtUtils::MakeMaker 6.78 + File::Find 0 + File::Next 0 + File::Path 0 + File::Spec 0 + Getopt::Long::Descriptive 0 + IO::Compress::Gzip 0 + Module::Faker::Dist 0.015 + Moose 0 + Sort::Versions 0 + Text::Template 0 + perl 5.014000 + strict 0 + warnings 0 + CPAN-Meta-2.150010 + pathname: D/DA/DAGOLDEN/CPAN-Meta-2.150010.tar.gz + provides: + CPAN::Meta 2.150010 + CPAN::Meta::Converter 2.150010 + CPAN::Meta::Feature 2.150010 + CPAN::Meta::History 2.150010 + CPAN::Meta::Merge 2.150010 + CPAN::Meta::Prereqs 2.150010 + CPAN::Meta::Spec 2.150010 + CPAN::Meta::Validator 2.150010 + Parse::CPAN::Meta 2.150010 + requirements: + CPAN::Meta::Requirements 2.121 + CPAN::Meta::YAML 0.011 + Carp 0 + Encode 0 + Exporter 0 + ExtUtils::MakeMaker 6.17 + File::Spec 0.80 + JSON::PP 2.27300 + Scalar::Util 0 + perl 5.008001 + strict 0 + version 0.88 + warnings 0 + CPAN-Meta-Requirements-2.143 + pathname: R/RJ/RJBS/CPAN-Meta-Requirements-2.143.tar.gz + provides: + CPAN::Meta::Requirements 2.143 + CPAN::Meta::Requirements::Range 2.143 + requirements: + B 0 + Carp 0 + ExtUtils::MakeMaker 6.17 + perl 5.010000 + strict 0 + version 0.88 + warnings 0 + CPAN-Meta-YAML-0.020 + pathname: E/ET/ETHER/CPAN-Meta-YAML-0.020.tar.gz + provides: + CPAN::Meta::YAML 0.020 + requirements: + B 0 + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 6.17 + Fcntl 0 + Scalar::Util 0 + perl 5.008001 + strict 0 + warnings 0 + CPAN-Repository-0.010 + pathname: O/OA/OALDERS/CPAN-Repository-0.010.tar.gz + provides: + CPAN::Repository 0.010 + CPAN::Repository::Mailrc 0.010 + CPAN::Repository::Packages 0.010 + CPAN::Repository::Perms 0.010 + CPAN::Repository::Role::File 0.010 + requirements: + DateTime 0.72 + DateTime::Format::Epoch 0.13 + DateTime::Format::RFC3339 0 + Dist::Data 0.002 + ExtUtils::MakeMaker 0 + File::Path 2.08 + File::Spec::Functions 3.33 + IO::File 1.14 + IO::Zlib 1.10 + Moo 0.009013 + Cache-LRU-0.04 + pathname: K/KA/KAZUHO/Cache-LRU-0.04.tar.gz + provides: + Cache::LRU 0.04 + requirements: + ExtUtils::MakeMaker 6.42 + Test::More 0.88 + Test::Requires 0 + perl 5.008001 + Call-Context-0.05 + pathname: F/FE/FELIPE/Call-Context-0.05.tar.gz + provides: + Call::Context 0.05 + Call::Context::X 0.05 + requirements: + ExtUtils::MakeMaker 0 + Canary-Stability-2013 + pathname: M/ML/MLEHMANN/Canary-Stability-2013.tar.gz + provides: + Canary::Stability 2013 + requirements: + ExtUtils::MakeMaker 0 + Capture-Tiny-0.50 + pathname: D/DA/DAGOLDEN/Capture-Tiny-0.50.tar.gz + provides: + Capture::Tiny 0.50 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 6.17 + File::Spec 0 + File::Temp 0 + IO::Handle 0 + Scalar::Util 0 + perl 5.006 + strict 0 + warnings 0 + Carp-Assert-0.22 + pathname: Y/YV/YVES/Carp-Assert-0.22.tar.gz + provides: + Carp::Assert 0.22 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + perl 5.006 + strict 0 + vars 0 + warnings 0 + Carp-Clan-6.08 + pathname: E/ET/ETHER/Carp-Clan-6.08.tar.gz + provides: + Carp::Clan 6.08 + requirements: + ExtUtils::MakeMaker 0 + overload 0 + perl 5.006 + strict 0 + Catalyst-Action-REST-1.21 + pathname: J/JJ/JJNAPIORK/Catalyst-Action-REST-1.21.tar.gz + provides: + Catalyst::Action::Deserialize 1.21 + Catalyst::Action::Deserialize::Callback 1.21 + Catalyst::Action::Deserialize::JSON 1.21 + Catalyst::Action::Deserialize::JSON::XS 1.21 + Catalyst::Action::Deserialize::View 1.21 + Catalyst::Action::Deserialize::XML::Simple 1.21 + Catalyst::Action::Deserialize::YAML 1.21 + Catalyst::Action::DeserializeMultiPart 1.21 + Catalyst::Action::REST 1.21 + Catalyst::Action::REST::ForBrowsers 1.21 + Catalyst::Action::Serialize 1.21 + Catalyst::Action::Serialize::Callback 1.21 + Catalyst::Action::Serialize::JSON 1.21 + Catalyst::Action::Serialize::JSON::XS 1.21 + Catalyst::Action::Serialize::JSONP 1.21 + Catalyst::Action::Serialize::View 1.21 + Catalyst::Action::Serialize::XML::Simple 1.21 + Catalyst::Action::Serialize::YAML 1.21 + Catalyst::Action::Serialize::YAML::HTML 1.21 + Catalyst::Action::SerializeBase 1.21 + Catalyst::Controller::REST 1.21 + Catalyst::Request::REST 1.21 + Catalyst::Request::REST::ForBrowsers 1.21 + Catalyst::TraitFor::Request::REST 1.21 + Catalyst::TraitFor::Request::REST::ForBrowsers 1.21 + requirements: + Catalyst::Runtime 5.80030 + Class::Inspector 1.13 + ExtUtils::MakeMaker 0 + JSON::MaybeXS 0 + MRO::Compat 0.10 + Module::Pluggable::Object 0 + Moose 1.03 + Params::Validate 0.76 + URI::Find 0 + namespace::autoclean 0 + Catalyst-Action-RenderView-0.17 + pathname: H/HA/HAARG/Catalyst-Action-RenderView-0.17.tar.gz + provides: + Catalyst::Action::RenderView 0.17 + requirements: + Catalyst::Runtime 5.80030 + Data::Visitor 0.24 + ExtUtils::MakeMaker 0 + MRO::Compat 0 + perl 5.008005 + Catalyst-Plugin-Authentication-0.10024 + pathname: J/JJ/JJNAPIORK/Catalyst-Plugin-Authentication-0.10024.tar.gz + provides: + Catalyst::Authentication::Credential::NoPassword undef + Catalyst::Authentication::Credential::Password undef + Catalyst::Authentication::Credential::Remote undef + Catalyst::Authentication::Realm undef + Catalyst::Authentication::Realm::Compatibility undef + Catalyst::Authentication::Realm::Progressive undef + Catalyst::Authentication::Store::Minimal undef + Catalyst::Authentication::Store::Null undef + Catalyst::Authentication::User undef + Catalyst::Authentication::User::Hash undef + Catalyst::Plugin::Authentication 0.10024 + Catalyst::Plugin::Authentication::Credential::Password undef + Catalyst::Plugin::Authentication::Store::Minimal undef + Catalyst::Plugin::Authentication::User undef + Catalyst::Plugin::Authentication::User::Hash undef + requirements: + Catalyst::Runtime 0 + ExtUtils::MakeMaker 0 + MRO::Compat 0 + Moose 0 + MooseX::Emulate::Class::Accessor::Fast 0 + String::RewritePrefix 0 + Try::Tiny 0 + namespace::autoclean 0 + Catalyst-Plugin-ConfigLoader-0.35 + pathname: H/HA/HAARG/Catalyst-Plugin-ConfigLoader-0.35.tar.gz + provides: + Catalyst::Plugin::ConfigLoader 0.35 + requirements: + Catalyst::Runtime 5.7008 + Config::Any 0.20 + Data::Visitor 0.24 + ExtUtils::MakeMaker 0 + MRO::Compat 0.09 + Catalyst-Plugin-Session-0.43 + pathname: H/HA/HAARG/Catalyst-Plugin-Session-0.43.tar.gz + provides: + Catalyst::Plugin::Session 0.43 + Catalyst::Plugin::Session::State 0.43 + Catalyst::Plugin::Session::Store 0.43 + Catalyst::Plugin::Session::Store::Dummy 0.43 + Catalyst::Plugin::Session::Test::Store 0.43 + requirements: + Catalyst::Runtime 5.71001 + Digest 0 + ExtUtils::MakeMaker 0 + File::Spec 0 + File::Temp 0 + HTML::Entities 0 + List::Util 0 + MRO::Compat 0 + Moose 0.76 + MooseX::Emulate::Class::Accessor::Fast 0.00801 + Object::Signature 0 + Test::More 0.88 + namespace::clean 0.10 + perl 5.008 + Catalyst-Plugin-Session-State-Cookie-0.18 + pathname: H/HA/HAARG/Catalyst-Plugin-Session-State-Cookie-0.18.tar.gz + provides: + Catalyst::Plugin::Session::State::Cookie 0.18 + requirements: + Catalyst 5.80005 + Catalyst::Plugin::Session 0.27 + ExtUtils::MakeMaker 0 + MRO::Compat 0 + Moose 0 + namespace::autoclean 0 + Catalyst-Plugin-Static-Simple-0.37 + pathname: I/IL/ILMARI/Catalyst-Plugin-Static-Simple-0.37.tar.gz + provides: + Catalyst::Plugin::Static::Simple 0.37 + requirements: + Catalyst::Runtime 5.80008 + ExtUtils::MakeMaker 0 + MIME::Types 2.03 + Moose 0 + namespace::autoclean 0 + Catalyst-Runtime-5.90132 + pathname: J/JJ/JJNAPIORK/Catalyst-Runtime-5.90132.tar.gz + provides: + Catalyst 5.90132 + Catalyst::Action undef + Catalyst::ActionChain undef + Catalyst::ActionContainer undef + Catalyst::ActionRole::ConsumesContent undef + Catalyst::ActionRole::HTTPMethods undef + Catalyst::ActionRole::QueryMatching undef + Catalyst::ActionRole::Scheme undef + Catalyst::Base undef + Catalyst::ClassData undef + Catalyst::Component undef + Catalyst::Component::ApplicationAttribute undef + Catalyst::Component::ContextClosure undef + Catalyst::Controller undef + Catalyst::DispatchType undef + Catalyst::DispatchType::Chained undef + Catalyst::DispatchType::Default undef + Catalyst::DispatchType::Index undef + Catalyst::DispatchType::Path undef + Catalyst::Dispatcher undef + Catalyst::Engine undef + Catalyst::EngineLoader undef + Catalyst::Exception undef + Catalyst::Exception::Base undef + Catalyst::Exception::Basic undef + Catalyst::Exception::Detach undef + Catalyst::Exception::Go undef + Catalyst::Exception::Interface undef + Catalyst::Log undef + Catalyst::Middleware::Stash undef + Catalyst::Model undef + Catalyst::Plugin::Unicode::Encoding 5.90132 + Catalyst::Request undef + Catalyst::Request::PartData undef + Catalyst::Request::Upload undef + Catalyst::Response undef + Catalyst::Response::Writer undef + Catalyst::Runtime 5.90132 + Catalyst::Script::CGI undef + Catalyst::Script::Create undef + Catalyst::Script::FastCGI undef + Catalyst::Script::Server undef + Catalyst::Script::Test undef + Catalyst::ScriptRole undef + Catalyst::ScriptRunner undef + Catalyst::Stats undef + Catalyst::Test undef + Catalyst::Utils undef + Catalyst::View undef + requirements: + CGI::Simple::Cookie 1.109 + CGI::Struct 0 + Carp 1.25 + Class::C3::Adopt::NEXT 0.07 + Class::Load 0.12 + Data::Dump 0 + Data::OptList 0 + Devel::InnerPackage 0 + Encode 2.49 + ExtUtils::MakeMaker 0 + HTML::Entities 0 + HTML::HeadParser 0 + HTTP::Body 1.22 + HTTP::Headers 1.64 + HTTP::Request 5.814 + HTTP::Response 5.813 + Hash::MultiValue 0 + JSON::MaybeXS 1.000000 + LWP 5.837 + List::Util 1.45 + MRO::Compat 0 + Module::Pluggable 4.7 + Moose 2.1400 + MooseX::Emulate::Class::Accessor::Fast 0.00903 + MooseX::Getopt 0.48 + MooseX::MethodAttributes::Role::AttrContainer::Inheritable 0.24 + Path::Class 0.09 + PerlIO::utf8_strict 0 + Plack 0.9991 + Plack::Middleware::Conditional 0 + Plack::Middleware::ContentLength 0 + Plack::Middleware::FixMissingBodyInRedirect 0.09 + Plack::Middleware::HTTPExceptions 0 + Plack::Middleware::Head 0 + Plack::Middleware::IIS6ScriptNameFix 0 + Plack::Middleware::IIS7KeepAliveFix 0 + Plack::Middleware::LighttpdScriptNameFix 0 + Plack::Middleware::MethodOverride 0.12 + Plack::Middleware::RemoveRedundantBody 0.03 + Plack::Middleware::ReverseProxy 0.04 + Plack::Request::Upload 0 + Plack::Test::ExternalServer 0 + Safe::Isa 0 + Scalar::Util 0 + Socket 1.96 + Stream::Buffered 0 + String::RewritePrefix 0.004 + Sub::Exporter 0 + Task::Weaken 0 + Text::Balanced 0 + Text::SimpleTable 0.03 + Time::HiRes 0 + Tree::Simple 1.15 + Tree::Simple::Visitor::FindByUID 0 + Try::Tiny 0.17 + URI 1.65 + URI::ws 0.03 + namespace::clean 0.23 + perl 5.008003 + Catalyst-View-JSON-0.37 + pathname: H/HA/HAARG/Catalyst-View-JSON-0.37.tar.gz + provides: + Catalyst::Helper::View::JSON undef + Catalyst::View::JSON 0.37 + requirements: + Catalyst 5.60 + ExtUtils::MakeMaker 0 + JSON::MaybeXS 1.003000 + MRO::Compat 0 + CatalystX-Fastly-Role-Response-0.07 + pathname: H/HA/HAARG/CatalystX-Fastly-Role-Response-0.07.tar.gz + provides: + CatalystX::Fastly::Role::Response 0.07 + requirements: + ExtUtils::MakeMaker 0 + Moose::Role 2.2200 + perl 5.008005 + Class-Accessor-0.51 + pathname: K/KA/KASEI/Class-Accessor-0.51.tar.gz + provides: + Class::Accessor 0.51 + Class::Accessor::Fast 0.51 + Class::Accessor::Faster 0.51 + requirements: + ExtUtils::MakeMaker 0 + base 1.01 + Class-C3-Adopt-NEXT-0.14 + pathname: E/ET/ETHER/Class-C3-Adopt-NEXT-0.14.tar.gz + provides: + Class::C3::Adopt::NEXT 0.14 + requirements: + List::Util 1.33 + MRO::Compat 0 + Module::Build::Tiny 0.039 + NEXT 0 + perl 5.006 + strict 0 + warnings 0 + warnings::register 0 + Class-Data-Inheritable-0.10 + pathname: R/RS/RSHERER/Class-Data-Inheritable-0.10.tar.gz + provides: + Class::Data::Inheritable 0.10 + requirements: + ExtUtils::MakeMaker 0 + Class-Inspector-1.36 + pathname: P/PL/PLICEASE/Class-Inspector-1.36.tar.gz + provides: + Class::Inspector 1.36 + Class::Inspector::Functions 1.36 + requirements: + ExtUtils::MakeMaker 0 + File::Spec 0.80 + base 0 + perl 5.008 + Class-Load-0.25 + pathname: E/ET/ETHER/Class-Load-0.25.tar.gz + provides: + Class::Load 0.25 + Class::Load::PP 0.25 + requirements: + Carp 0 + Data::OptList 0.110 + Exporter 0 + ExtUtils::MakeMaker 0 + Module::Implementation 0.04 + Module::Runtime 0.012 + Package::Stash 0.14 + Scalar::Util 0 + Try::Tiny 0 + base 0 + perl 5.006 + strict 0 + warnings 0 + Class-Load-XS-0.10 + pathname: E/ET/ETHER/Class-Load-XS-0.10.tar.gz + provides: + Class::Load::XS 0.10 + requirements: + Class::Load 0.20 + ExtUtils::MakeMaker 0 + XSLoader 0 + perl 5.006 + strict 0 + warnings 0 + Class-Method-Modifiers-2.15 + pathname: E/ET/ETHER/Class-Method-Modifiers-2.15.tar.gz + provides: + Class::Method::Modifiers 2.15 + requirements: + B 0 + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + base 0 + perl 5.006 + strict 0 + warnings 0 + Class-Singleton-1.6 + pathname: S/SH/SHAY/Class-Singleton-1.6.tar.gz + provides: + Class::Singleton 1.6 + requirements: + ExtUtils::MakeMaker 6.64 + perl 5.008001 + strict 0 + warnings 0 + Class-Tiny-1.008 + pathname: D/DA/DAGOLDEN/Class-Tiny-1.008.tar.gz + provides: + Class::Tiny 1.008 + Class::Tiny::Object 1.008 + requirements: + Carp 0 + ExtUtils::MakeMaker 6.17 + perl 5.006 + strict 0 + warnings 0 + Class-Tiny-Chained-0.004 + pathname: D/DB/DBOOK/Class-Tiny-Chained-0.004.tar.gz + provides: + Class::Tiny::Chained 0.004 + requirements: + Class::Tiny 1.003 + ExtUtils::MakeMaker 0 + perl 5.006 + Class-XSAccessor-1.19 + pathname: S/SM/SMUELLER/Class-XSAccessor-1.19.tar.gz + provides: + Class::XSAccessor 1.19 + Class::XSAccessor::Array 1.19 + requirements: + ExtUtils::MakeMaker 0 + Test::More 0 + Time::HiRes 0 + XSLoader 0 + perl 5.008 + Clone-0.47 + pathname: A/AT/ATOOMIC/Clone-0.47.tar.gz + provides: + Clone 0.47 + requirements: + ExtUtils::MakeMaker 0 + Clone-Choose-0.010 + pathname: H/HE/HERMES/Clone-Choose-0.010.tar.gz + provides: + Clone::Choose 0.010 + requirements: + ExtUtils::MakeMaker 0 + Storable 0 + perl 5.008001 + Code-TidyAll-0.84 + pathname: D/DR/DROLSKY/Code-TidyAll-0.84.tar.gz + provides: + Code::TidyAll 0.84 + Code::TidyAll::Cache 0.84 + Code::TidyAll::CacheModel 0.84 + Code::TidyAll::CacheModel::Shared 0.84 + Code::TidyAll::Config::INI::Reader 0.84 + Code::TidyAll::Git::Precommit 0.84 + Code::TidyAll::Git::Prereceive 0.84 + Code::TidyAll::Git::Util 0.84 + Code::TidyAll::Plugin 0.84 + Code::TidyAll::Plugin::CSSUnminifier 0.84 + Code::TidyAll::Plugin::GenericTransformer 0.84 + Code::TidyAll::Plugin::GenericValidator 0.84 + Code::TidyAll::Plugin::JSBeautify 0.84 + Code::TidyAll::Plugin::JSHint 0.84 + Code::TidyAll::Plugin::JSLint 0.84 + Code::TidyAll::Plugin::JSON 0.84 + Code::TidyAll::Plugin::MasonTidy 0.84 + Code::TidyAll::Plugin::PHPCodeSniffer 0.84 + Code::TidyAll::Plugin::PerlCritic 0.84 + Code::TidyAll::Plugin::PerlTidy 0.84 + Code::TidyAll::Plugin::PerlTidySweet 0.84 + Code::TidyAll::Plugin::PodChecker 0.84 + Code::TidyAll::Plugin::PodSpell 0.84 + Code::TidyAll::Plugin::PodTidy 0.84 + Code::TidyAll::Plugin::SortLines 0.84 + Code::TidyAll::Result 0.84 + Code::TidyAll::Role::GenericExecutable 0.84 + Code::TidyAll::Role::HasIgnore 0.84 + Code::TidyAll::Role::RunsCommand 0.84 + Code::TidyAll::Role::Tempdir 0.84 + Code::TidyAll::SVN::Precommit 0.84 + Code::TidyAll::SVN::Util 0.84 + Code::TidyAll::Util::Zglob 0.84 + Code::TidyAll::Zglob 0.84 + Test::Code::TidyAll 0.84 + requirements: + Capture::Tiny 0 + Config::INI::Reader 0 + Cwd 0 + Data::Dumper 0 + Date::Format 0 + Digest::SHA 0 + Exporter 0 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Find 0 + File::Spec 0 + File::Which 0 + File::pushd 0 + Getopt::Long 0 + IPC::Run3 0 + IPC::System::Simple 0 + List::Compare 0 + List::SomeUtils 0 + Log::Any 0 + Module::Runtime 0 + Moo 2.000000 + Moo::Role 0 + Path::Tiny 0.098 + Scalar::Util 0 + Scope::Guard 0 + Specio 0.40 + Specio::Declare 0 + Specio::Library::Builtins 0 + Specio::Library::Numeric 0 + Specio::Library::Path::Tiny 0.04 + Specio::Library::String 0 + Test::Builder 0 + Text::Diff 1.44 + Text::Diff::Table 0 + Text::ParseWords 0 + Time::Duration::Parse 0 + Try::Tiny 0 + base 0 + constant 0 + perl 5.008008 + strict 0 + warnings 0 + Code-TidyAll-Plugin-UniqueLines-0.000003 + pathname: O/OA/OALDERS/Code-TidyAll-Plugin-UniqueLines-0.000003.tar.gz + provides: + Code::TidyAll::Plugin::UniqueLines 0.000003 + requirements: + Code::TidyAll::Plugin 0 + ExtUtils::MakeMaker 0 + List::Util 1.45 + Moo 0 + perl 5.006 + strict 0 + warnings 0 + Compress-Bzip2-2.28 + pathname: R/RU/RURBAN/Compress-Bzip2-2.28.tar.gz + provides: + Compress::Bzip2 2.28 + requirements: + Carp 0 + Config 0 + ExtUtils::MakeMaker 0 + Fcntl 0 + File::Copy 0 + File::Spec 0 + Getopt::Std 0 + Test::More 0 + constant 1.04 + Compress-Raw-Bzip2-2.213 + pathname: P/PM/PMQS/Compress-Raw-Bzip2-2.213.tar.gz + provides: + Compress::Raw::Bzip2 2.213 + requirements: + ExtUtils::MakeMaker 0 + Compress-Raw-Zlib-2.213 + pathname: P/PM/PMQS/Compress-Raw-Zlib-2.213.tar.gz + provides: + Compress::Raw::Zlib 2.213 + requirements: + ExtUtils::MakeMaker 0 + Config-Any-0.33 + pathname: H/HA/HAARG/Config-Any-0.33.tar.gz + provides: + Config::Any 0.33 + Config::Any::Base undef + Config::Any::General undef + Config::Any::INI undef + Config::Any::JSON undef + Config::Any::Perl undef + Config::Any::XML undef + Config::Any::YAML undef + requirements: + Module::Pluggable::Object 3.6 + Config-General-2.67 + pathname: T/TL/TLINDEN/Config-General-2.67.tar.gz + provides: + Config::General 2.67 + Config::General::Extended 2.07 + Config::General::Interpolated 2.16 + requirements: + ExtUtils::MakeMaker 0 + File::Glob 0 + File::Spec::Functions 0 + FileHandle 0 + IO::File 0 + Config-INI-0.029 + pathname: R/RJ/RJBS/Config-INI-0.029.tar.gz + provides: + Config::INI 0.029 + Config::INI::Reader 0.029 + Config::INI::Writer 0.029 + requirements: + Carp 0 + ExtUtils::MakeMaker 6.78 + Mixin::Linewise::Readers 0.110 + Mixin::Linewise::Writers 0 + perl 5.012 + warnings 0 + Config-Tiny-2.30 + pathname: R/RS/RSAVAGE/Config-Tiny-2.30.tgz + provides: + Config::Tiny 2.30 + requirements: + ExtUtils::MakeMaker 0 + File::Spec 3.30 + File::Temp 0.22 + Test::More 0.47 + perl 5.008001 + strict 0 + utf8 0 + Config-ZOMG-1.000000 + pathname: F/FR/FREW/Config-ZOMG-1.000000.tar.gz + provides: + Config::ZOMG 1.000000 + Config::ZOMG::Source::Loader 1.000000 + requirements: + Clone 0 + Config::Any 0 + ExtUtils::MakeMaker 6.30 + Hash::Merge::Simple 0 + List::Util 0 + Moo 0 + Const-Fast-0.014 + pathname: L/LE/LEONT/Const-Fast-0.014.tar.gz + provides: + Const::Fast 0.014 + requirements: + Carp 0 + Module::Build::Tiny 0.021 + Scalar::Util 0 + Storable 0 + Sub::Exporter::Progressive 0.001007 + perl 5.008 + strict 0 + warnings 0 + Cookie-Baker-0.12 + pathname: K/KA/KAZEBURO/Cookie-Baker-0.12.tar.gz + provides: + Cookie::Baker 0.12 + requirements: + Exporter 0 + Module::Build::Tiny 0.035 + URI::Escape 0 + perl 5.008001 + Cpanel-JSON-XS-4.39 + pathname: R/RU/RURBAN/Cpanel-JSON-XS-4.39.tar.gz + provides: + Cpanel::JSON::XS 4.39 + Cpanel::JSON::XS::Type undef + requirements: + Carp 0 + Config 0 + Encode 1.9801 + Exporter 0 + ExtUtils::MakeMaker 0 + Pod::Text 2.08 + XSLoader 0 + overload 0 + strict 0 + warnings 0 + Crypt-SysRandom-0.007 + pathname: L/LE/LEONT/Crypt-SysRandom-0.007.tar.gz + provides: + Crypt::SysRandom 0.007 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + perl 5.006 + strict 0 + warnings 0 + Crypt-URandom-0.54 + pathname: D/DD/DDICK/Crypt-URandom-0.54.tar.gz + provides: + Crypt::URandom 0.54 + requirements: + Carp 1.26 + English 0 + Exporter 0 + ExtUtils::MakeMaker 0 + FileHandle 0 + constant 0 + perl 5.006 + DBD-Pg-3.18.0 + pathname: T/TU/TURNSTEP/DBD-Pg-3.18.0.tar.gz + provides: + Bundle::DBD::Pg v3.18.0 + DBD::Pg v3.18.0 + requirements: + DBI 1.614 + ExtUtils::MakeMaker 6.58 + File::Temp 0 + Test::More 0.88 + Time::HiRes 0 + version 0 + DBD-SQLite-1.76 + pathname: I/IS/ISHIGAKI/DBD-SQLite-1.76.tar.gz + provides: + DBD::SQLite 1.76 + DBD::SQLite::Constants undef + DBD::SQLite::GetInfo undef + DBD::SQLite::VirtualTable 1.76 + DBD::SQLite::VirtualTable::Cursor 1.76 + DBD::SQLite::VirtualTable::FileContent undef + DBD::SQLite::VirtualTable::FileContent::Cursor undef + DBD::SQLite::VirtualTable::PerlData undef + DBD::SQLite::VirtualTable::PerlData::Cursor undef + requirements: + DBI 1.57 + ExtUtils::MakeMaker 0 + File::Spec 0.82 + Test::More 0.88 + Tie::Hash 0 + perl 5.006 + DBI-1.647 + pathname: H/HM/HMBRAND/DBI-1.647.tgz + provides: + Bundle::DBI 12.008696 + DBD::DBM 0.08 + DBD::DBM::Statement 0.08 + DBD::DBM::Table 0.08 + DBD::DBM::db 0.08 + DBD::DBM::dr 0.08 + DBD::DBM::st 0.08 + DBD::ExampleP 12.014311 + DBD::ExampleP::db 12.014311 + DBD::ExampleP::dr 12.014311 + DBD::ExampleP::st 12.014311 + DBD::File 0.44 + DBD::File::DataSource::File 0.44 + DBD::File::DataSource::Stream 0.44 + DBD::File::Statement 0.44 + DBD::File::Table 0.44 + DBD::File::TableSource::FileSystem 0.44 + DBD::File::db 0.44 + DBD::File::dr 0.44 + DBD::File::st 0.44 + DBD::Gofer 0.015327 + DBD::Gofer::Policy::Base 0.010088 + DBD::Gofer::Policy::classic 0.010088 + DBD::Gofer::Policy::pedantic 0.010088 + DBD::Gofer::Policy::rush 0.010088 + DBD::Gofer::Transport::Base 0.014121 + DBD::Gofer::Transport::corostream undef + DBD::Gofer::Transport::null 0.010088 + DBD::Gofer::Transport::pipeone 0.010088 + DBD::Gofer::Transport::stream 0.014599 + DBD::Gofer::db 0.015327 + DBD::Gofer::dr 0.015327 + DBD::Gofer::st 0.015327 + DBD::Mem 0.001 + DBD::Mem::DataSource 0.001 + DBD::Mem::Statement 0.001 + DBD::Mem::Table 0.001 + DBD::Mem::db 0.001 + DBD::Mem::dr 0.001 + DBD::Mem::st 0.001 + DBD::NullP 12.014715 + DBD::NullP::db 12.014715 + DBD::NullP::dr 12.014715 + DBD::NullP::st 12.014715 + DBD::Proxy 0.2004 + DBD::Proxy::RPC::PlClient 0.2004 + DBD::Proxy::db 0.2004 + DBD::Proxy::dr 0.2004 + DBD::Proxy::st 0.2004 + DBD::Sponge 12.010003 + DBD::Sponge::db 12.010003 + DBD::Sponge::dr 12.010003 + DBD::Sponge::st 12.010003 + DBDI 12.015129 + DBI 1.647 + DBI::Const::GetInfo::ANSI 2.008697 + DBI::Const::GetInfo::ODBC 2.011374 + DBI::Const::GetInfoReturn 2.008697 + DBI::Const::GetInfoType 2.008697 + DBI::DBD 12.015129 + DBI::DBD::Metadata 2.014214 + DBI::DBD::SqlEngine 0.06 + DBI::DBD::SqlEngine::DataSource 0.06 + DBI::DBD::SqlEngine::Statement 0.06 + DBI::DBD::SqlEngine::Table 0.06 + DBI::DBD::SqlEngine::TableSource 0.06 + DBI::DBD::SqlEngine::TieMeta 0.06 + DBI::DBD::SqlEngine::TieTables 0.06 + DBI::DBD::SqlEngine::db 0.06 + DBI::DBD::SqlEngine::dr 0.06 + DBI::DBD::SqlEngine::st 0.06 + DBI::Gofer::Execute 0.014283 + DBI::Gofer::Request 0.012537 + DBI::Gofer::Response 0.011566 + DBI::Gofer::Serializer::Base 0.009950 + DBI::Gofer::Serializer::DataDumper 0.009950 + DBI::Gofer::Serializer::Storable 0.015586 + DBI::Gofer::Transport::Base 0.012537 + DBI::Gofer::Transport::pipeone 0.012537 + DBI::Gofer::Transport::stream 0.012537 + DBI::Profile 2.015065 + DBI::ProfileData 2.010008 + DBI::ProfileDumper 2.015325 + DBI::ProfileDumper::Apache 2.014121 + DBI::ProfileSubs 0.009396 + DBI::ProxyServer 0.3005 + DBI::ProxyServer::db 0.3005 + DBI::ProxyServer::dr 0.3005 + DBI::ProxyServer::st 0.3005 + DBI::SQL::Nano 1.015544 + DBI::SQL::Nano::Statement_ 1.015544 + DBI::SQL::Nano::Table_ 1.015544 + DBI::Util::CacheMemory 0.010315 + DBI::Util::_accessor 0.009479 + DBI::common 1.647 + requirements: + ExtUtils::MakeMaker 6.48 + Test::Simple 0.90 + perl 5.008001 + Data-Compare-1.29 + pathname: D/DC/DCANTRELL/Data-Compare-1.29.tar.gz + provides: + Data::Compare 1.29 + Data::Compare::Plugins::Scalar::Properties 1.25 + requirements: + Clone 0.43 + ExtUtils::MakeMaker 6.48 + File::Find::Rule 0.1 + Scalar::Util 0 + Test::More 0.88 + perl 5.006 + Data-Dump-1.25 + pathname: G/GA/GARU/Data-Dump-1.25.tar.gz + provides: + Data::Dump 1.25 + Data::Dump::FilterContext undef + Data::Dump::Filtered undef + Data::Dump::Trace 0.02 + Data::Dump::Trace::Call 0.02 + Data::Dump::Trace::Wrapper 0.02 + requirements: + ExtUtils::MakeMaker 0 + Symbol 0 + Test 0 + perl 5.006 + Data-Dumper-Concise-2.023 + pathname: E/ET/ETHER/Data-Dumper-Concise-2.023.tar.gz + provides: + Data::Dumper::Concise 2.023 + Data::Dumper::Concise::Sugar 2.023 + Devel::Dwarn undef + requirements: + Data::Dumper 0 + Exporter 0 + ExtUtils::MakeMaker 0 + perl 5.006 + Data-OptList-0.114 + pathname: R/RJ/RJBS/Data-OptList-0.114.tar.gz + provides: + Data::OptList 0.114 + requirements: + ExtUtils::MakeMaker 6.78 + List::Util 0 + Params::Util 0 + Sub::Install 0.921 + perl 5.012 + strict 0 + warnings 0 + Data-Section-0.200008 + pathname: R/RJ/RJBS/Data-Section-0.200008.tar.gz + provides: + Data::Section 0.200008 + requirements: + Encode 0 + ExtUtils::MakeMaker 6.78 + MRO::Compat 0.09 + Sub::Exporter 0.979 + perl 5.012 + strict 0 + warnings 0 + Data-UUID-1.227 + pathname: G/GT/GTERMARS/Data-UUID-1.227.tar.gz + provides: + Data::UUID 1.227 + requirements: + Digest::MD5 0 + ExtUtils::MakeMaker 0 + Data-Visitor-0.32 + pathname: E/ET/ETHER/Data-Visitor-0.32.tar.gz + provides: + Data::Visitor 0.32 + Data::Visitor::Callback 0.32 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + Moose 0.89 + Scalar::Util 0 + Symbol 0 + Tie::ToObject 0.01 + constant 0 + namespace::clean 0.19 + overload 0 + perl 5.006 + strict 0 + warnings 0 + DateTime-1.66 + pathname: D/DR/DROLSKY/DateTime-1.66.tar.gz + provides: + DateTime 1.66 + DateTime::Duration 1.66 + DateTime::Helpers 1.66 + DateTime::Infinite 1.66 + DateTime::Infinite::Future 1.66 + DateTime::Infinite::Past 1.66 + DateTime::LeapSecond 1.66 + DateTime::PP 1.66 + DateTime::PPExtra 1.66 + DateTime::Types 1.66 + requirements: + Carp 0 + DateTime::Locale 1.06 + DateTime::TimeZone 2.44 + Dist::CheckConflicts 0.02 + ExtUtils::MakeMaker 0 + POSIX 0 + Params::ValidationCompiler 0.26 + Scalar::Util 0 + Specio 0.50 + Specio::Declare 0 + Specio::Exporter 0 + Specio::Library::Builtins 0 + Specio::Library::Numeric 0 + Specio::Library::String 0 + Specio::Subs 0 + Try::Tiny 0 + XSLoader 0 + integer 0 + namespace::autoclean 0.19 + overload 0 + parent 0 + perl 5.008004 + strict 0 + warnings 0 + warnings::register 0 + DateTime-Format-Builder-0.83 + pathname: D/DR/DROLSKY/DateTime-Format-Builder-0.83.tar.gz + provides: + DateTime::Format::Builder 0.83 + DateTime::Format::Builder::Parser 0.83 + DateTime::Format::Builder::Parser::Dispatch 0.83 + DateTime::Format::Builder::Parser::Quick 0.83 + DateTime::Format::Builder::Parser::Regex 0.83 + DateTime::Format::Builder::Parser::Strptime 0.83 + DateTime::Format::Builder::Parser::generic 0.83 + requirements: + Carp 0 + DateTime 1.00 + DateTime::Format::Strptime 1.04 + ExtUtils::MakeMaker 0 + Params::Validate 0.72 + Scalar::Util 0 + parent 0 + strict 0 + warnings 0 + DateTime-Format-Epoch-0.16 + pathname: C/CH/CHORNY/DateTime-Format-Epoch-0.16.tar.gz + provides: + DateTime::Format::Epoch 0.16 + DateTime::Format::Epoch::ActiveDirectory 0.13 + DateTime::Format::Epoch::DotNet 0.13 + DateTime::Format::Epoch::JD 0.13 + DateTime::Format::Epoch::Lilian 0.13 + DateTime::Format::Epoch::MJD 0.13 + DateTime::Format::Epoch::MacOS 0.13 + DateTime::Format::Epoch::NTP 0.14 + DateTime::Format::Epoch::RJD 0.13 + DateTime::Format::Epoch::RataDie 0.13 + DateTime::Format::Epoch::TAI64 0.13 + DateTime::Format::Epoch::TJD 0.13 + DateTime::Format::Epoch::Unix 0.13 + requirements: + DateTime 0.31 + Math::BigInt 1.66 + Params::Validate 0 + Test::More 0 + perl 5.00503 + warnings 0 + DateTime-Format-ISO8601-0.17 + pathname: D/DR/DROLSKY/DateTime-Format-ISO8601-0.17.tar.gz + provides: + DateTime::Format::ISO8601 0.17 + DateTime::Format::ISO8601::Types 0.17 + requirements: + Carp 0 + DateTime 1.45 + DateTime::Format::Builder 0.77 + ExtUtils::MakeMaker 0 + Params::ValidationCompiler 0.26 + Specio 0.18 + Specio::Declare 0 + Specio::Exporter 0 + Specio::Library::Builtins 0 + namespace::autoclean 0 + parent 0 + strict 0 + warnings 0 + DateTime-Format-RFC3339-v1.10.0 + pathname: I/IK/IKEGAMI/DateTime-Format-RFC3339-v1.10.0.tar.gz + provides: + DateTime::Format::RFC3339 1.010000 + requirements: + DateTime 0 + ExtUtils::MakeMaker 0 + perl 5.01 + strict 0 + version 0 + warnings 0 + DateTime-Format-Strptime-1.79 + pathname: D/DR/DROLSKY/DateTime-Format-Strptime-1.79.tar.gz + provides: + DateTime::Format::Strptime 1.79 + DateTime::Format::Strptime::Types 1.79 + requirements: + Carp 0 + DateTime 1.00 + DateTime::Locale 1.30 + DateTime::Locale::Base 0 + DateTime::Locale::FromData 0 + DateTime::TimeZone 2.09 + Exporter 0 + ExtUtils::MakeMaker 0 + Params::ValidationCompiler 0 + Specio 0.33 + Specio::Declare 0 + Specio::Exporter 0 + Specio::Library::Builtins 0 + Specio::Library::String 0 + Try::Tiny 0 + constant 0 + parent 0 + strict 0 + warnings 0 + DateTime-Locale-1.45 + pathname: D/DR/DROLSKY/DateTime-Locale-1.45.tar.gz + provides: + DateTime::Locale 1.45 + DateTime::Locale::Base 1.45 + DateTime::Locale::Catalog 1.45 + DateTime::Locale::Data 1.45 + DateTime::Locale::FromData 1.45 + DateTime::Locale::Util 1.45 + requirements: + Carp 0 + Dist::CheckConflicts 0.02 + Exporter 0 + ExtUtils::MakeMaker 0 + File::ShareDir 0 + File::ShareDir::Install 0.06 + File::Spec 0 + List::Util 1.45 + Params::ValidationCompiler 0.13 + Specio::Declare 0 + Specio::Library::String 0 + Storable 0 + namespace::autoclean 0.19 + perl 5.008004 + strict 0 + warnings 0 + DateTime-TimeZone-2.65 + pathname: D/DR/DROLSKY/DateTime-TimeZone-2.65.tar.gz + provides: + DateTime::TimeZone 2.65 + DateTime::TimeZone::Africa::Abidjan 2.65 + DateTime::TimeZone::Africa::Algiers 2.65 + DateTime::TimeZone::Africa::Bissau 2.65 + DateTime::TimeZone::Africa::Cairo 2.65 + DateTime::TimeZone::Africa::Casablanca 2.65 + DateTime::TimeZone::Africa::Ceuta 2.65 + DateTime::TimeZone::Africa::El_Aaiun 2.65 + DateTime::TimeZone::Africa::Johannesburg 2.65 + DateTime::TimeZone::Africa::Juba 2.65 + DateTime::TimeZone::Africa::Khartoum 2.65 + DateTime::TimeZone::Africa::Lagos 2.65 + DateTime::TimeZone::Africa::Maputo 2.65 + DateTime::TimeZone::Africa::Monrovia 2.65 + DateTime::TimeZone::Africa::Nairobi 2.65 + DateTime::TimeZone::Africa::Ndjamena 2.65 + DateTime::TimeZone::Africa::Sao_Tome 2.65 + DateTime::TimeZone::Africa::Tripoli 2.65 + DateTime::TimeZone::Africa::Tunis 2.65 + DateTime::TimeZone::Africa::Windhoek 2.65 + DateTime::TimeZone::America::Adak 2.65 + DateTime::TimeZone::America::Anchorage 2.65 + DateTime::TimeZone::America::Araguaina 2.65 + DateTime::TimeZone::America::Argentina::Buenos_Aires 2.65 + DateTime::TimeZone::America::Argentina::Catamarca 2.65 + DateTime::TimeZone::America::Argentina::Cordoba 2.65 + DateTime::TimeZone::America::Argentina::Jujuy 2.65 + DateTime::TimeZone::America::Argentina::La_Rioja 2.65 + DateTime::TimeZone::America::Argentina::Mendoza 2.65 + DateTime::TimeZone::America::Argentina::Rio_Gallegos 2.65 + DateTime::TimeZone::America::Argentina::Salta 2.65 + DateTime::TimeZone::America::Argentina::San_Juan 2.65 + DateTime::TimeZone::America::Argentina::San_Luis 2.65 + DateTime::TimeZone::America::Argentina::Tucuman 2.65 + DateTime::TimeZone::America::Argentina::Ushuaia 2.65 + DateTime::TimeZone::America::Asuncion 2.65 + DateTime::TimeZone::America::Bahia 2.65 + DateTime::TimeZone::America::Bahia_Banderas 2.65 + DateTime::TimeZone::America::Barbados 2.65 + DateTime::TimeZone::America::Belem 2.65 + DateTime::TimeZone::America::Belize 2.65 + DateTime::TimeZone::America::Boa_Vista 2.65 + DateTime::TimeZone::America::Bogota 2.65 + DateTime::TimeZone::America::Boise 2.65 + DateTime::TimeZone::America::Cambridge_Bay 2.65 + DateTime::TimeZone::America::Campo_Grande 2.65 + DateTime::TimeZone::America::Cancun 2.65 + DateTime::TimeZone::America::Caracas 2.65 + DateTime::TimeZone::America::Cayenne 2.65 + DateTime::TimeZone::America::Chicago 2.65 + DateTime::TimeZone::America::Chihuahua 2.65 + DateTime::TimeZone::America::Ciudad_Juarez 2.65 + DateTime::TimeZone::America::Costa_Rica 2.65 + DateTime::TimeZone::America::Coyhaique 2.65 + DateTime::TimeZone::America::Cuiaba 2.65 + DateTime::TimeZone::America::Danmarkshavn 2.65 + DateTime::TimeZone::America::Dawson 2.65 + DateTime::TimeZone::America::Dawson_Creek 2.65 + DateTime::TimeZone::America::Denver 2.65 + DateTime::TimeZone::America::Detroit 2.65 + DateTime::TimeZone::America::Edmonton 2.65 + DateTime::TimeZone::America::Eirunepe 2.65 + DateTime::TimeZone::America::El_Salvador 2.65 + DateTime::TimeZone::America::Fort_Nelson 2.65 + DateTime::TimeZone::America::Fortaleza 2.65 + DateTime::TimeZone::America::Glace_Bay 2.65 + DateTime::TimeZone::America::Goose_Bay 2.65 + DateTime::TimeZone::America::Grand_Turk 2.65 + DateTime::TimeZone::America::Guatemala 2.65 + DateTime::TimeZone::America::Guayaquil 2.65 + DateTime::TimeZone::America::Guyana 2.65 + DateTime::TimeZone::America::Halifax 2.65 + DateTime::TimeZone::America::Havana 2.65 + DateTime::TimeZone::America::Hermosillo 2.65 + DateTime::TimeZone::America::Indiana::Indianapolis 2.65 + DateTime::TimeZone::America::Indiana::Knox 2.65 + DateTime::TimeZone::America::Indiana::Marengo 2.65 + DateTime::TimeZone::America::Indiana::Petersburg 2.65 + DateTime::TimeZone::America::Indiana::Tell_City 2.65 + DateTime::TimeZone::America::Indiana::Vevay 2.65 + DateTime::TimeZone::America::Indiana::Vincennes 2.65 + DateTime::TimeZone::America::Indiana::Winamac 2.65 + DateTime::TimeZone::America::Inuvik 2.65 + DateTime::TimeZone::America::Iqaluit 2.65 + DateTime::TimeZone::America::Jamaica 2.65 + DateTime::TimeZone::America::Juneau 2.65 + DateTime::TimeZone::America::Kentucky::Louisville 2.65 + DateTime::TimeZone::America::Kentucky::Monticello 2.65 + DateTime::TimeZone::America::La_Paz 2.65 + DateTime::TimeZone::America::Lima 2.65 + DateTime::TimeZone::America::Los_Angeles 2.65 + DateTime::TimeZone::America::Maceio 2.65 + DateTime::TimeZone::America::Managua 2.65 + DateTime::TimeZone::America::Manaus 2.65 + DateTime::TimeZone::America::Martinique 2.65 + DateTime::TimeZone::America::Matamoros 2.65 + DateTime::TimeZone::America::Mazatlan 2.65 + DateTime::TimeZone::America::Menominee 2.65 + DateTime::TimeZone::America::Merida 2.65 + DateTime::TimeZone::America::Metlakatla 2.65 + DateTime::TimeZone::America::Mexico_City 2.65 + DateTime::TimeZone::America::Miquelon 2.65 + DateTime::TimeZone::America::Moncton 2.65 + DateTime::TimeZone::America::Monterrey 2.65 + DateTime::TimeZone::America::Montevideo 2.65 + DateTime::TimeZone::America::New_York 2.65 + DateTime::TimeZone::America::Nome 2.65 + DateTime::TimeZone::America::Noronha 2.65 + DateTime::TimeZone::America::North_Dakota::Beulah 2.65 + DateTime::TimeZone::America::North_Dakota::Center 2.65 + DateTime::TimeZone::America::North_Dakota::New_Salem 2.65 + DateTime::TimeZone::America::Nuuk 2.65 + DateTime::TimeZone::America::Ojinaga 2.65 + DateTime::TimeZone::America::Panama 2.65 + DateTime::TimeZone::America::Paramaribo 2.65 + DateTime::TimeZone::America::Phoenix 2.65 + DateTime::TimeZone::America::Port_au_Prince 2.65 + DateTime::TimeZone::America::Porto_Velho 2.65 + DateTime::TimeZone::America::Puerto_Rico 2.65 + DateTime::TimeZone::America::Punta_Arenas 2.65 + DateTime::TimeZone::America::Rankin_Inlet 2.65 + DateTime::TimeZone::America::Recife 2.65 + DateTime::TimeZone::America::Regina 2.65 + DateTime::TimeZone::America::Resolute 2.65 + DateTime::TimeZone::America::Rio_Branco 2.65 + DateTime::TimeZone::America::Santarem 2.65 + DateTime::TimeZone::America::Santiago 2.65 + DateTime::TimeZone::America::Santo_Domingo 2.65 + DateTime::TimeZone::America::Sao_Paulo 2.65 + DateTime::TimeZone::America::Scoresbysund 2.65 + DateTime::TimeZone::America::Sitka 2.65 + DateTime::TimeZone::America::St_Johns 2.65 + DateTime::TimeZone::America::Swift_Current 2.65 + DateTime::TimeZone::America::Tegucigalpa 2.65 + DateTime::TimeZone::America::Thule 2.65 + DateTime::TimeZone::America::Tijuana 2.65 + DateTime::TimeZone::America::Toronto 2.65 + DateTime::TimeZone::America::Vancouver 2.65 + DateTime::TimeZone::America::Whitehorse 2.65 + DateTime::TimeZone::America::Winnipeg 2.65 + DateTime::TimeZone::America::Yakutat 2.65 + DateTime::TimeZone::Antarctica::Casey 2.65 + DateTime::TimeZone::Antarctica::Davis 2.65 + DateTime::TimeZone::Antarctica::Macquarie 2.65 + DateTime::TimeZone::Antarctica::Mawson 2.65 + DateTime::TimeZone::Antarctica::Palmer 2.65 + DateTime::TimeZone::Antarctica::Rothera 2.65 + DateTime::TimeZone::Antarctica::Troll 2.65 + DateTime::TimeZone::Antarctica::Vostok 2.65 + DateTime::TimeZone::Asia::Almaty 2.65 + DateTime::TimeZone::Asia::Amman 2.65 + DateTime::TimeZone::Asia::Anadyr 2.65 + DateTime::TimeZone::Asia::Aqtau 2.65 + DateTime::TimeZone::Asia::Aqtobe 2.65 + DateTime::TimeZone::Asia::Ashgabat 2.65 + DateTime::TimeZone::Asia::Atyrau 2.65 + DateTime::TimeZone::Asia::Baghdad 2.65 + DateTime::TimeZone::Asia::Baku 2.65 + DateTime::TimeZone::Asia::Bangkok 2.65 + DateTime::TimeZone::Asia::Barnaul 2.65 + DateTime::TimeZone::Asia::Beirut 2.65 + DateTime::TimeZone::Asia::Bishkek 2.65 + DateTime::TimeZone::Asia::Chita 2.65 + DateTime::TimeZone::Asia::Colombo 2.65 + DateTime::TimeZone::Asia::Damascus 2.65 + DateTime::TimeZone::Asia::Dhaka 2.65 + DateTime::TimeZone::Asia::Dili 2.65 + DateTime::TimeZone::Asia::Dubai 2.65 + DateTime::TimeZone::Asia::Dushanbe 2.65 + DateTime::TimeZone::Asia::Famagusta 2.65 + DateTime::TimeZone::Asia::Gaza 2.65 + DateTime::TimeZone::Asia::Hebron 2.65 + DateTime::TimeZone::Asia::Ho_Chi_Minh 2.65 + DateTime::TimeZone::Asia::Hong_Kong 2.65 + DateTime::TimeZone::Asia::Hovd 2.65 + DateTime::TimeZone::Asia::Irkutsk 2.65 + DateTime::TimeZone::Asia::Jakarta 2.65 + DateTime::TimeZone::Asia::Jayapura 2.65 + DateTime::TimeZone::Asia::Jerusalem 2.65 + DateTime::TimeZone::Asia::Kabul 2.65 + DateTime::TimeZone::Asia::Kamchatka 2.65 + DateTime::TimeZone::Asia::Karachi 2.65 + DateTime::TimeZone::Asia::Kathmandu 2.65 + DateTime::TimeZone::Asia::Khandyga 2.65 + DateTime::TimeZone::Asia::Kolkata 2.65 + DateTime::TimeZone::Asia::Krasnoyarsk 2.65 + DateTime::TimeZone::Asia::Kuching 2.65 + DateTime::TimeZone::Asia::Macau 2.65 + DateTime::TimeZone::Asia::Magadan 2.65 + DateTime::TimeZone::Asia::Makassar 2.65 + DateTime::TimeZone::Asia::Manila 2.65 + DateTime::TimeZone::Asia::Nicosia 2.65 + DateTime::TimeZone::Asia::Novokuznetsk 2.65 + DateTime::TimeZone::Asia::Novosibirsk 2.65 + DateTime::TimeZone::Asia::Omsk 2.65 + DateTime::TimeZone::Asia::Oral 2.65 + DateTime::TimeZone::Asia::Pontianak 2.65 + DateTime::TimeZone::Asia::Pyongyang 2.65 + DateTime::TimeZone::Asia::Qatar 2.65 + DateTime::TimeZone::Asia::Qostanay 2.65 + DateTime::TimeZone::Asia::Qyzylorda 2.65 + DateTime::TimeZone::Asia::Riyadh 2.65 + DateTime::TimeZone::Asia::Sakhalin 2.65 + DateTime::TimeZone::Asia::Samarkand 2.65 + DateTime::TimeZone::Asia::Seoul 2.65 + DateTime::TimeZone::Asia::Shanghai 2.65 + DateTime::TimeZone::Asia::Singapore 2.65 + DateTime::TimeZone::Asia::Srednekolymsk 2.65 + DateTime::TimeZone::Asia::Taipei 2.65 + DateTime::TimeZone::Asia::Tashkent 2.65 + DateTime::TimeZone::Asia::Tbilisi 2.65 + DateTime::TimeZone::Asia::Tehran 2.65 + DateTime::TimeZone::Asia::Thimphu 2.65 + DateTime::TimeZone::Asia::Tokyo 2.65 + DateTime::TimeZone::Asia::Tomsk 2.65 + DateTime::TimeZone::Asia::Ulaanbaatar 2.65 + DateTime::TimeZone::Asia::Urumqi 2.65 + DateTime::TimeZone::Asia::Ust_Nera 2.65 + DateTime::TimeZone::Asia::Vladivostok 2.65 + DateTime::TimeZone::Asia::Yakutsk 2.65 + DateTime::TimeZone::Asia::Yangon 2.65 + DateTime::TimeZone::Asia::Yekaterinburg 2.65 + DateTime::TimeZone::Asia::Yerevan 2.65 + DateTime::TimeZone::Atlantic::Azores 2.65 + DateTime::TimeZone::Atlantic::Bermuda 2.65 + DateTime::TimeZone::Atlantic::Canary 2.65 + DateTime::TimeZone::Atlantic::Cape_Verde 2.65 + DateTime::TimeZone::Atlantic::Faroe 2.65 + DateTime::TimeZone::Atlantic::Madeira 2.65 + DateTime::TimeZone::Atlantic::South_Georgia 2.65 + DateTime::TimeZone::Atlantic::Stanley 2.65 + DateTime::TimeZone::Australia::Adelaide 2.65 + DateTime::TimeZone::Australia::Brisbane 2.65 + DateTime::TimeZone::Australia::Broken_Hill 2.65 + DateTime::TimeZone::Australia::Darwin 2.65 + DateTime::TimeZone::Australia::Eucla 2.65 + DateTime::TimeZone::Australia::Hobart 2.65 + DateTime::TimeZone::Australia::Lindeman 2.65 + DateTime::TimeZone::Australia::Lord_Howe 2.65 + DateTime::TimeZone::Australia::Melbourne 2.65 + DateTime::TimeZone::Australia::Perth 2.65 + DateTime::TimeZone::Australia::Sydney 2.65 + DateTime::TimeZone::Catalog 2.65 + DateTime::TimeZone::Europe::Andorra 2.65 + DateTime::TimeZone::Europe::Astrakhan 2.65 + DateTime::TimeZone::Europe::Athens 2.65 + DateTime::TimeZone::Europe::Belgrade 2.65 + DateTime::TimeZone::Europe::Berlin 2.65 + DateTime::TimeZone::Europe::Brussels 2.65 + DateTime::TimeZone::Europe::Bucharest 2.65 + DateTime::TimeZone::Europe::Budapest 2.65 + DateTime::TimeZone::Europe::Chisinau 2.65 + DateTime::TimeZone::Europe::Dublin 2.65 + DateTime::TimeZone::Europe::Gibraltar 2.65 + DateTime::TimeZone::Europe::Helsinki 2.65 + DateTime::TimeZone::Europe::Istanbul 2.65 + DateTime::TimeZone::Europe::Kaliningrad 2.65 + DateTime::TimeZone::Europe::Kirov 2.65 + DateTime::TimeZone::Europe::Kyiv 2.65 + DateTime::TimeZone::Europe::Lisbon 2.65 + DateTime::TimeZone::Europe::London 2.65 + DateTime::TimeZone::Europe::Madrid 2.65 + DateTime::TimeZone::Europe::Malta 2.65 + DateTime::TimeZone::Europe::Minsk 2.65 + DateTime::TimeZone::Europe::Moscow 2.65 + DateTime::TimeZone::Europe::Paris 2.65 + DateTime::TimeZone::Europe::Prague 2.65 + DateTime::TimeZone::Europe::Riga 2.65 + DateTime::TimeZone::Europe::Rome 2.65 + DateTime::TimeZone::Europe::Samara 2.65 + DateTime::TimeZone::Europe::Saratov 2.65 + DateTime::TimeZone::Europe::Simferopol 2.65 + DateTime::TimeZone::Europe::Sofia 2.65 + DateTime::TimeZone::Europe::Tallinn 2.65 + DateTime::TimeZone::Europe::Tirane 2.65 + DateTime::TimeZone::Europe::Ulyanovsk 2.65 + DateTime::TimeZone::Europe::Vienna 2.65 + DateTime::TimeZone::Europe::Vilnius 2.65 + DateTime::TimeZone::Europe::Volgograd 2.65 + DateTime::TimeZone::Europe::Warsaw 2.65 + DateTime::TimeZone::Europe::Zurich 2.65 + DateTime::TimeZone::Floating 2.65 + DateTime::TimeZone::Indian::Chagos 2.65 + DateTime::TimeZone::Indian::Maldives 2.65 + DateTime::TimeZone::Indian::Mauritius 2.65 + DateTime::TimeZone::Local 2.65 + DateTime::TimeZone::Local::Android 2.65 + DateTime::TimeZone::Local::Unix 2.65 + DateTime::TimeZone::Local::VMS 2.65 + DateTime::TimeZone::OffsetOnly 2.65 + DateTime::TimeZone::OlsonDB 2.65 + DateTime::TimeZone::OlsonDB::Change 2.65 + DateTime::TimeZone::OlsonDB::Observance 2.65 + DateTime::TimeZone::OlsonDB::Rule 2.65 + DateTime::TimeZone::OlsonDB::Zone 2.65 + DateTime::TimeZone::Pacific::Apia 2.65 + DateTime::TimeZone::Pacific::Auckland 2.65 + DateTime::TimeZone::Pacific::Bougainville 2.65 + DateTime::TimeZone::Pacific::Chatham 2.65 + DateTime::TimeZone::Pacific::Easter 2.65 + DateTime::TimeZone::Pacific::Efate 2.65 + DateTime::TimeZone::Pacific::Fakaofo 2.65 + DateTime::TimeZone::Pacific::Fiji 2.65 + DateTime::TimeZone::Pacific::Galapagos 2.65 + DateTime::TimeZone::Pacific::Gambier 2.65 + DateTime::TimeZone::Pacific::Guadalcanal 2.65 + DateTime::TimeZone::Pacific::Guam 2.65 + DateTime::TimeZone::Pacific::Honolulu 2.65 + DateTime::TimeZone::Pacific::Kanton 2.65 + DateTime::TimeZone::Pacific::Kiritimati 2.65 + DateTime::TimeZone::Pacific::Kosrae 2.65 + DateTime::TimeZone::Pacific::Kwajalein 2.65 + DateTime::TimeZone::Pacific::Marquesas 2.65 + DateTime::TimeZone::Pacific::Nauru 2.65 + DateTime::TimeZone::Pacific::Niue 2.65 + DateTime::TimeZone::Pacific::Norfolk 2.65 + DateTime::TimeZone::Pacific::Noumea 2.65 + DateTime::TimeZone::Pacific::Pago_Pago 2.65 + DateTime::TimeZone::Pacific::Palau 2.65 + DateTime::TimeZone::Pacific::Pitcairn 2.65 + DateTime::TimeZone::Pacific::Port_Moresby 2.65 + DateTime::TimeZone::Pacific::Rarotonga 2.65 + DateTime::TimeZone::Pacific::Tahiti 2.65 + DateTime::TimeZone::Pacific::Tarawa 2.65 + DateTime::TimeZone::Pacific::Tongatapu 2.65 + DateTime::TimeZone::UTC 2.65 + requirements: + Class::Singleton 1.03 + Cwd 3 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Compare 0 + File::Find 0 + File::Spec 0 + List::Util 1.33 + Module::Runtime 0 + Params::ValidationCompiler 0.13 + Specio::Library::Builtins 0 + Specio::Library::String 0 + Try::Tiny 0 + constant 0 + namespace::autoclean 0 + parent 0 + perl 5.008004 + strict 0 + warnings 0 + Devel-CheckLib-1.16 + pathname: M/MA/MATTN/Devel-CheckLib-1.16.tar.gz + provides: + Devel::CheckLib 1.16 + requirements: + Exporter 0 + ExtUtils::MakeMaker 0 + File::Spec 0 + File::Temp 0.16 + perl 5.004050 + Devel-Confess-0.009004 + pathname: H/HA/HAARG/Devel-Confess-0.009004.tar.gz + provides: + Devel::Confess 0.009004 + Devel::Confess::Builtin 0.009004 + Devel::Confess::Source undef + Devel::Confess::_Util undef + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + Scalar::Util 0 + perl 5.006 + Devel-GlobalDestruction-0.14 + pathname: H/HA/HAARG/Devel-GlobalDestruction-0.14.tar.gz + provides: + Devel::GlobalDestruction 0.14 + requirements: + ExtUtils::MakeMaker 0 + Sub::Exporter::Progressive 0.001011 + perl 5.006 + Devel-Hide-0.0015 + pathname: D/DC/DCANTRELL/Devel-Hide-0.0015.tar.gz + provides: + Devel::Hide 0.0015 + requirements: + ExtUtils::MakeMaker 0 + File::Temp 0 + perl 5.006001 + Devel-OverloadInfo-0.007 + pathname: I/IL/ILMARI/Devel-OverloadInfo-0.007.tar.gz + provides: + Devel::OverloadInfo 0.007 + requirements: + B 0 + Exporter 5.57 + ExtUtils::MakeMaker 0 + MRO::Compat 0 + Package::Stash 0.14 + Scalar::Util 0 + Sub::Util 1.40 + Text::ParseWords 0 + overload 0 + perl 5.006 + strict 0 + warnings 0 + Devel-PartialDump-0.20 + pathname: E/ET/ETHER/Devel-PartialDump-0.20.tar.gz + provides: + Devel::PartialDump 0.20 + requirements: + Carp 0 + Class::Tiny 0 + ExtUtils::MakeMaker 0 + Scalar::Util 0 + Sub::Exporter 0 + namespace::clean 0.19 + perl 5.006001 + strict 0 + warnings 0 + Devel-StackTrace-2.05 + pathname: D/DR/DROLSKY/Devel-StackTrace-2.05.tar.gz + provides: + Devel::StackTrace 2.05 + Devel::StackTrace::Frame 2.05 + requirements: + ExtUtils::MakeMaker 0 + File::Spec 0 + Scalar::Util 0 + overload 0 + perl 5.006 + strict 0 + warnings 0 + Devel-StackTrace-AsHTML-0.15 + pathname: M/MI/MIYAGAWA/Devel-StackTrace-AsHTML-0.15.tar.gz + provides: + Devel::StackTrace::AsHTML 0.15 + requirements: + Devel::StackTrace 0 + ExtUtils::MakeMaker 0 + Digest-HMAC-1.05 + pathname: A/AR/ARODLAND/Digest-HMAC-1.05.tar.gz + provides: + Digest::HMAC 1.05 + Digest::HMAC_MD5 1.05 + Digest::HMAC_SHA1 1.05 + requirements: + Digest::MD5 2 + Digest::SHA 1 + ExtUtils::MakeMaker 0 + perl 5.008001 + Digest-JHash-0.10 + pathname: S/SH/SHLOMIF/Digest-JHash-0.10.tar.gz + provides: + Digest::JHash 0.10 + requirements: + DynaLoader 0 + Exporter 0 + ExtUtils::MakeMaker 0 + perl 5.008 + strict 0 + vars 0 + warnings 0 + Dist-CheckConflicts-0.11 + pathname: D/DO/DOY/Dist-CheckConflicts-0.11.tar.gz + provides: + Dist::CheckConflicts 0.11 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 6.30 + Module::Runtime 0.009 + base 0 + strict 0 + warnings 0 + Dist-Data-0.007 + pathname: G/GE/GETTY/Dist-Data-0.007.tar.gz + provides: + Dist::Data 0.007 + requirements: + Archive::Any 0.0932 + CPAN::Meta 2.113640 + DateTime::Format::Epoch 0.13 + Dist::Metadata 0.922 + ExtUtils::MakeMaker 0 + File::Find::Object v0.2.3 + File::Temp 0.22 + Module::Metadata 0 + Moo 0.009013 + Dist-Metadata-0.927 + pathname: R/RW/RWSTAUNER/Dist-Metadata-0.927.tar.gz + provides: + Dist::Metadata 0.927 + Dist::Metadata::Archive 0.927 + Dist::Metadata::Dir 0.927 + Dist::Metadata::Dist 0.927 + Dist::Metadata::Struct 0.927 + Dist::Metadata::Tar 0.927 + Dist::Metadata::Zip 0.927 + requirements: + Archive::Tar 1 + Archive::Zip 1.30 + CPAN::DistnameInfo 0.12 + CPAN::Meta 2.1 + Carp 0 + Digest 1.03 + Digest::MD5 2 + Digest::SHA 5 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Find 0 + File::Spec::Native 1.002 + File::Temp 0.19 + List::Util 0 + Module::Metadata 0 + Path::Class 0.24 + Try::Tiny 0.09 + parent 0 + perl 5.006 + strict 0 + warnings 0 + EV-4.34 + pathname: M/ML/MLEHMANN/EV-4.34.tar.gz + provides: + EV 4.34 + EV::MakeMaker undef + requirements: + Canary::Stability 0 + ExtUtils::MakeMaker 6.52 + common::sense 0 + ElasticSearchX-Model-2.0.1 + pathname: O/OA/OALDERS/ElasticSearchX-Model-2.0.1.tar.gz + provides: + ElasticSearchX::Model v2.0.1 + ElasticSearchX::Model::Bulk v2.0.1 + ElasticSearchX::Model::Document v2.0.1 + ElasticSearchX::Model::Document::EmbeddedRole v2.0.1 + ElasticSearchX::Model::Document::Mapping v2.0.1 + ElasticSearchX::Model::Document::Role v2.0.1 + ElasticSearchX::Model::Document::Set v2.0.1 + ElasticSearchX::Model::Document::Trait::Attribute v2.0.1 + ElasticSearchX::Model::Document::Trait::Class v2.0.1 + ElasticSearchX::Model::Document::Trait::Class::ID v2.0.1 + ElasticSearchX::Model::Document::Trait::Class::Timestamp v2.0.1 + ElasticSearchX::Model::Document::Trait::Class::Version v2.0.1 + ElasticSearchX::Model::Document::Trait::Field::ID v2.0.1 + ElasticSearchX::Model::Document::Trait::Field::TTL v2.0.1 + ElasticSearchX::Model::Document::Trait::Field::Timestamp v2.0.1 + ElasticSearchX::Model::Document::Trait::Field::Version v2.0.1 + ElasticSearchX::Model::Document::Types v2.0.1 + ElasticSearchX::Model::Index v2.0.1 + ElasticSearchX::Model::Role v2.0.1 + ElasticSearchX::Model::Scroll v2.0.1 + ElasticSearchX::Model::Trait::Class v2.0.1 + ElasticSearchX::Model::Tutorial v2.0.1 + ElasticSearchX::Model::Util v2.0.1 + requirements: + Carp 0 + Class::Load 0 + DateTime 0 + DateTime::Format::Epoch::Unix 0 + DateTime::Format::ISO8601 0 + Digest::SHA 0 + Eval::Closure 0 + ExtUtils::MakeMaker 0 + JSON::MaybeXS 0 + List::MoreUtils 0 + List::Util 0 + Module::Find 0 + Moose 2.02 + Moose::Exporter 0 + Moose::Role 0 + Moose::Util::TypeConstraints 0 + MooseX::Attribute::Chained v1.0.1 + MooseX::Attribute::ChainedClone 0 + MooseX::Attribute::Deflator v2.2.0 + MooseX::Attribute::Deflator::Moose 0 + MooseX::Attribute::LazyInflator::Meta::Role::Attribute 0 + MooseX::Types 0 + MooseX::Types::ElasticSearch v0.0.4 + MooseX::Types::Moose 0 + MooseX::Types::Structured 0 + Scalar::Util 0 + Search::Elasticsearch 2.02 + Sub::Exporter 0 + perl 5.006 + strict 0 + version 0 + warnings 0 + Email-Abstract-3.010 + pathname: R/RJ/RJBS/Email-Abstract-3.010.tar.gz + provides: + Email::Abstract 3.010 + Email::Abstract::EmailMIME 3.010 + Email::Abstract::EmailSimple 3.010 + Email::Abstract::MIMEEntity 3.010 + Email::Abstract::MailInternet 3.010 + Email::Abstract::MailMessage 3.010 + Email::Abstract::Plugin 3.010 + requirements: + Carp 0 + Email::Simple 1.998 + ExtUtils::MakeMaker 6.78 + MRO::Compat 0 + Module::Pluggable 1.5 + Scalar::Util 0 + perl 5.006 + strict 0 + warnings 0 + Email-Address-XS-1.05 + pathname: P/PA/PALI/Email-Address-XS-1.05.tar.gz + provides: + Email::Address::XS 1.05 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + XSLoader 0 + base 0 + overload 0 + perl 5.006000 + strict 0 + warnings 0 + Email-Date-Format-1.008 + pathname: R/RJ/RJBS/Email-Date-Format-1.008.tar.gz + provides: + Email::Date::Format 1.008 + requirements: + Exporter 5.57 + ExtUtils::MakeMaker 6.78 + Time::Local 1.27 + perl 5.012 + warnings 0 + Email-Sender-2.601 + pathname: R/RJ/RJBS/Email-Sender-2.601.tar.gz + provides: + Email::Sender 2.601 + Email::Sender::Failure 2.601 + Email::Sender::Failure::Multi 2.601 + Email::Sender::Failure::Permanent 2.601 + Email::Sender::Failure::Temporary 2.601 + Email::Sender::Manual 2.601 + Email::Sender::Manual::QuickStart 2.601 + Email::Sender::Role::CommonSending 2.601 + Email::Sender::Role::HasMessage 2.601 + Email::Sender::Simple 2.601 + Email::Sender::Success 2.601 + Email::Sender::Success::Partial 2.601 + Email::Sender::Transport 2.601 + Email::Sender::Transport::DevNull 2.601 + Email::Sender::Transport::Failable 2.601 + Email::Sender::Transport::Maildir 2.601 + Email::Sender::Transport::Mbox 2.601 + Email::Sender::Transport::Print 2.601 + Email::Sender::Transport::SMTP 2.601 + Email::Sender::Transport::SMTP::Persistent 2.601 + Email::Sender::Transport::Sendmail 2.601 + Email::Sender::Transport::Test 2.601 + Email::Sender::Transport::Wrapper 2.601 + Email::Sender::Util 2.601 + requirements: + Carp 0 + Email::Abstract 3.006 + Email::Address::XS 0 + Email::Simple 1.998 + ExtUtils::MakeMaker 6.78 + Fcntl 0 + File::Basename 0 + File::Path 2.06 + File::Spec 0 + IO::File 1.11 + IO::Handle 0 + List::Util 1.45 + Module::Runtime 0 + Moo 2.000000 + Moo::Role 0 + MooX::Types::MooseLike 0.15 + MooX::Types::MooseLike::Base 0 + Net::SMTP 3.07 + Scalar::Util 0 + Sub::Exporter 0 + Sub::Exporter::Util 0 + Sys::Hostname 0 + Throwable::Error 0.200003 + Try::Tiny 0 + perl 5.012 + strict 0 + utf8 0 + warnings 0 + Email-Simple-2.218 + pathname: R/RJ/RJBS/Email-Simple-2.218.tar.gz + provides: + Email::Simple 2.218 + Email::Simple::Creator 2.218 + Email::Simple::Header 2.218 + requirements: + Carp 0 + Email::Date::Format 0 + ExtUtils::MakeMaker 6.78 + perl 5.012 + strict 0 + warnings 0 + Email-Valid-1.204 + pathname: R/RJ/RJBS/Email-Valid-1.204.tar.gz + provides: + Email::Valid 1.204 + requirements: + ExtUtils::MakeMaker 0 + Mail::Address 0 + Net::DNS 0 + Scalar::Util 0 + Test::More 0 + perl 5.006 + Encode-3.21 + pathname: D/DA/DANKOGAI/Encode-3.21.tar.gz + provides: + Encode 3.21 + Encode::Alias 2.25 + Encode::Byte 2.04 + Encode::CJKConstants 2.02 + Encode::CN 2.03 + Encode::CN::HZ 2.10 + Encode::Config 2.05 + Encode::EBCDIC 2.02 + Encode::Encoder 2.03 + Encode::Encoding 2.08 + Encode::GSM0338 2.10 + Encode::Guess 2.08 + Encode::JP 2.05 + Encode::JP::H2Z 2.02 + Encode::JP::JIS7 2.08 + Encode::KR 2.03 + Encode::KR::2022_KR 2.04 + Encode::MIME::Header 2.29 + Encode::MIME::Header::ISO_2022_JP 1.09 + Encode::MIME::Name 1.03 + Encode::Symbol 2.02 + Encode::TW 2.03 + Encode::UTF_EBCDIC 3.21 + Encode::Unicode 2.20 + Encode::Unicode::UTF7 2.10 + Encode::XS 3.21 + Encode::utf8 3.21 + encoding 3.00 + requirements: + Exporter 5.57 + ExtUtils::MakeMaker 0 + Storable 0 + parent 0.221 + Encode-Locale-1.05 + pathname: G/GA/GAAS/Encode-Locale-1.05.tar.gz + provides: + Encode::Locale 1.05 + requirements: + Encode 2 + Encode::Alias 0 + ExtUtils::MakeMaker 0 + perl 5.008 + Encoding-FixLatin-1.04 + pathname: G/GR/GRANTM/Encoding-FixLatin-1.04.tar.gz + provides: + Encoding::FixLatin 1.04 + requirements: + ExtUtils::MakeMaker 6.30 + Test::More 0.90 + Encoding-FixLatin-XS-1.02 + pathname: G/GR/GRANTM/Encoding-FixLatin-XS-1.02.tar.gz + provides: + Encoding::FixLatin::XS 1.02 + requirements: + ExtUtils::MakeMaker 0 + perl 5.014 + Eval-Closure-0.14 + pathname: D/DO/DOY/Eval-Closure-0.14.tar.gz + provides: + Eval::Closure 0.14 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + Scalar::Util 0 + constant 0 + overload 0 + strict 0 + warnings 0 + Exception-Class-1.45 + pathname: D/DR/DROLSKY/Exception-Class-1.45.tar.gz + provides: + Exception::Class 1.45 + Exception::Class::Base 1.45 + requirements: + Carp 0 + Class::Data::Inheritable 0.02 + Devel::StackTrace 2.00 + ExtUtils::MakeMaker 0 + Scalar::Util 0 + base 0 + overload 0 + perl 5.008001 + strict 0 + warnings 0 + Exporter-5.78 + pathname: T/TO/TODDR/Exporter-5.78.tar.gz + provides: + Exporter 5.78 + Exporter::Heavy 5.78 + requirements: + Carp 1.05 + ExtUtils::MakeMaker 0 + Exporter-Tiny-1.006002 + pathname: T/TO/TOBYINK/Exporter-Tiny-1.006002.tar.gz + provides: + Exporter::Shiny 1.006002 + Exporter::Tiny 1.006002 + requirements: + ExtUtils::MakeMaker 6.17 + perl 5.006001 + ExtUtils-Config-0.010 + pathname: L/LE/LEONT/ExtUtils-Config-0.010.tar.gz + provides: + ExtUtils::Config 0.010 + ExtUtils::Config::MakeMaker 0.010 + requirements: + Data::Dumper 0 + ExtUtils::MakeMaker 0 + ExtUtils::MakeMaker::Config 0 + perl 5.006 + strict 0 + warnings 0 + ExtUtils-Depends-0.8002 + pathname: E/ET/ETJ/ExtUtils-Depends-0.8002.tar.gz + provides: + ExtUtils::Depends 0.8002 + requirements: + Data::Dumper 0 + ExtUtils::MakeMaker 7.44 + File::Spec 0 + IO::File 0 + perl 5.006 + ExtUtils-Helpers-0.028 + pathname: L/LE/LEONT/ExtUtils-Helpers-0.028.tar.gz + provides: + ExtUtils::Helpers 0.028 + ExtUtils::Helpers::Unix 0.028 + ExtUtils::Helpers::VMS 0.028 + ExtUtils::Helpers::Windows 0.028 + requirements: + Carp 0 + Exporter 5.57 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Copy 0 + File::Spec::Functions 0 + Text::ParseWords 3.24 + strict 0 + warnings 0 + ExtUtils-InstallPaths-0.014 + pathname: L/LE/LEONT/ExtUtils-InstallPaths-0.014.tar.gz + provides: + ExtUtils::InstallPaths 0.014 + requirements: + Carp 0 + ExtUtils::Config 0.009 + ExtUtils::MakeMaker 0 + File::Spec 0 + perl 5.008 + strict 0 + warnings 0 + ExtUtils-MakeMaker-7.74 + pathname: B/BI/BINGOS/ExtUtils-MakeMaker-7.74.tar.gz + provides: + ExtUtils::Command 7.74 + ExtUtils::Command::MM 7.74 + ExtUtils::Liblist 7.74 + ExtUtils::Liblist::Kid 7.74 + ExtUtils::MM 7.74 + ExtUtils::MM_AIX 7.74 + ExtUtils::MM_Any 7.74 + ExtUtils::MM_BeOS 7.74 + ExtUtils::MM_Cygwin 7.74 + ExtUtils::MM_DOS 7.74 + ExtUtils::MM_Darwin 7.74 + ExtUtils::MM_MacOS 7.74 + ExtUtils::MM_NW5 7.74 + ExtUtils::MM_OS2 7.74 + ExtUtils::MM_OS390 7.74 + ExtUtils::MM_QNX 7.74 + ExtUtils::MM_UWIN 7.74 + ExtUtils::MM_Unix 7.74 + ExtUtils::MM_VMS 7.74 + ExtUtils::MM_VOS 7.74 + ExtUtils::MM_Win32 7.74 + ExtUtils::MM_Win95 7.74 + ExtUtils::MY 7.74 + ExtUtils::MakeMaker 7.74 + ExtUtils::MakeMaker::Config 7.74 + ExtUtils::MakeMaker::Locale 7.74 + ExtUtils::MakeMaker::_version 7.74 + ExtUtils::MakeMaker::charstar 7.74 + ExtUtils::MakeMaker::version 7.74 + ExtUtils::MakeMaker::version::regex 7.74 + ExtUtils::MakeMaker::version::vpp 7.74 + ExtUtils::Mkbootstrap 7.74 + ExtUtils::Mksymlists 7.74 + ExtUtils::testlib 7.74 + MM 7.74 + MY 7.74 + requirements: + Data::Dumper 0 + Encode 0 + File::Basename 0 + File::Spec 0.8 + Pod::Man 0 + perl 5.006 + ExtUtils-MakeMaker-CPANfile-0.09 + pathname: I/IS/ISHIGAKI/ExtUtils-MakeMaker-CPANfile-0.09.tar.gz + provides: + ExtUtils::MakeMaker::CPANfile 0.09 + requirements: + CPAN::Meta::Converter 2.141170 + Cwd 0 + ExtUtils::MakeMaker 6.17 + File::Path 0 + Module::CPANfile 0 + Test::More 0.88 + version 0.76 + File-Find-Object-0.3.9 + pathname: S/SH/SHLOMIF/File-Find-Object-0.3.9.tar.gz + provides: + File::Find::Object v0.3.9 + File::Find::Object::Base v0.3.9 + File::Find::Object::DeepPath v0.3.9 + File::Find::Object::PathComp v0.3.9 + File::Find::Object::Result v0.3.9 + File::Find::Object::TopPath v0.3.9 + requirements: + Carp 0 + Class::XSAccessor 0 + ExtUtils::MakeMaker 0 + Fcntl 0 + File::Spec 0 + List::Util 0 + Module::Build 0.28 + integer 0 + parent 0 + perl 5.008 + strict 0 + warnings 0 + File-Find-Rule-0.34 + pathname: R/RC/RCLAMP/File-Find-Rule-0.34.tar.gz + provides: + File::Find::Rule 0.34 + File::Find::Rule::Test::ATeam undef + requirements: + ExtUtils::MakeMaker 0 + File::Find 0 + File::Spec 0 + Number::Compare 0 + Test::More 0 + Text::Glob 0.07 + File-Find-Rule-Perl-1.16 + pathname: E/ET/ETHER/File-Find-Rule-Perl-1.16.tar.gz + provides: + File::Find::Rule::Perl 1.16 + requirements: + ExtUtils::MakeMaker 0 + File::Find::Rule 0.20 + File::Spec 0.82 + Params::Util 0.38 + Parse::CPAN::Meta 1.38 + perl 5.006 + File-HomeDir-1.006 + pathname: R/RE/REHSACK/File-HomeDir-1.006.tar.gz + provides: + File::HomeDir 1.006 + File::HomeDir::Darwin 1.006 + File::HomeDir::Darwin::Carbon 1.006 + File::HomeDir::Darwin::Cocoa 1.006 + File::HomeDir::Driver 1.006 + File::HomeDir::FreeDesktop 1.006 + File::HomeDir::MacOS9 1.006 + File::HomeDir::Test 1.006 + File::HomeDir::Unix 1.006 + File::HomeDir::Windows 1.006 + requirements: + Carp 0 + Cwd 3.12 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Path 2.01 + File::Spec 3.12 + File::Temp 0.19 + File::Which 0.05 + POSIX 0 + perl 5.008003 + File-Listing-6.16 + pathname: P/PL/PLICEASE/File-Listing-6.16.tar.gz + provides: + File::Listing 6.16 + File::Listing::apache 6.16 + File::Listing::dosftp 6.16 + File::Listing::netware 6.16 + File::Listing::unix 6.16 + File::Listing::vms 6.16 + requirements: + Exporter 5.57 + ExtUtils::MakeMaker 0 + HTTP::Date 0 + perl 5.006 + File-MMagic-1.30 + pathname: K/KN/KNOK/File-MMagic-1.30.tar.gz + provides: + File::MMagic 1.30 + requirements: + ExtUtils::MakeMaker 0 + File-Next-1.18 + pathname: P/PE/PETDANCE/File-Next-1.18.tar.gz + provides: + File::Next 1.18 + requirements: + ExtUtils::MakeMaker 0 + File::Copy 0 + File::Spec 0 + File::Temp 0.22 + Test::More 0.88 + File-ShareDir-1.118 + pathname: R/RE/REHSACK/File-ShareDir-1.118.tar.gz + provides: + File::ShareDir 1.118 + requirements: + Carp 0 + Class::Inspector 1.12 + ExtUtils::MakeMaker 0 + File::ShareDir::Install 0.13 + File::Spec 0.80 + perl 5.008001 + warnings 0 + File-ShareDir-Install-0.14 + pathname: E/ET/ETHER/File-ShareDir-Install-0.14.tar.gz + provides: + File::ShareDir::Install 0.14 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + File::Spec 0 + IO::Dir 0 + perl 5.006 + strict 0 + warnings 0 + File-Spec-Native-1.004 + pathname: R/RW/RWSTAUNER/File-Spec-Native-1.004.tar.gz + provides: + File::Spec::Native 1.004 + requirements: + ExtUtils::MakeMaker 0 + File::Spec 0 + perl 5.006 + strict 0 + warnings 0 + File-Which-1.27 + pathname: P/PL/PLICEASE/File-Which-1.27.tar.gz + provides: + File::Which 1.27 + requirements: + ExtUtils::MakeMaker 0 + base 0 + perl 5.006 + File-XDG-1.03 + pathname: P/PL/PLICEASE/File-XDG-1.03.tar.gz + provides: + File::XDG 1.03 + requirements: + ExtUtils::MakeMaker 0 + File::Path 2.07 + Path::Tiny 0 + Ref::Util 0 + perl 5.006 + File-pushd-1.016 + pathname: D/DA/DAGOLDEN/File-pushd-1.016.tar.gz + provides: + File::pushd 1.016 + requirements: + Carp 0 + Cwd 0 + Exporter 0 + ExtUtils::MakeMaker 6.17 + File::Path 0 + File::Spec 0 + File::Temp 0 + overload 0 + perl 5.006 + strict 0 + warnings 0 + Filesys-Notify-Simple-0.14 + pathname: M/MI/MIYAGAWA/Filesys-Notify-Simple-0.14.tar.gz + provides: + Filesys::Notify::Simple 0.14 + requirements: + ExtUtils::MakeMaker 0 + perl 5.008001 + Getopt-Long-2.58 + pathname: J/JV/JV/Getopt-Long-2.58.tar.gz + provides: + Getopt::Long 2.58 + Getopt::Long::Parser 2.58 + requirements: + ExtUtils::MakeMaker 0 + Pod::Usage 1.14 + Getopt-Long-Descriptive-0.116 + pathname: R/RJ/RJBS/Getopt-Long-Descriptive-0.116.tar.gz + provides: + Getopt::Long::Descriptive 0.116 + Getopt::Long::Descriptive::Opts 0.116 + Getopt::Long::Descriptive::Usage 0.116 + requirements: + Carp 0 + ExtUtils::MakeMaker 6.78 + File::Basename 0 + Getopt::Long 2.55 + List::Util 0 + Params::Validate 0.97 + Scalar::Util 0 + Sub::Exporter 0.972 + Sub::Exporter::Util 0 + overload 0 + perl 5.012 + strict 0 + warnings 0 + Gravatar-URL-1.07 + pathname: M/MS/MSCHWERN/Gravatar-URL-1.07.tar.gz + provides: + Gravatar::URL 1.07 + Libravatar::URL 1.07 + Unicornify::URL 1.07 + requirements: + Carp 0 + Digest::MD5 0 + Digest::SHA 0 + Net::DNS 1.01 + Test::MockRandom 1.01 + Test::More 0.4 + Test::Warn 0.11 + URI::Escape 0 + parent 0 + perl v5.6.0 + HTML-Parser-3.83 + pathname: O/OA/OALDERS/HTML-Parser-3.83.tar.gz + provides: + HTML::Entities 3.83 + HTML::Filter 3.83 + HTML::HeadParser 3.83 + HTML::LinkExtor 3.83 + HTML::Parser 3.83 + HTML::PullParser 3.83 + HTML::TokeParser 3.83 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 6.52 + HTML::Tagset 0 + HTTP::Headers 0 + IO::File 0 + URI 0 + URI::URL 0 + XSLoader 0 + strict 0 + HTML-Tagset-3.24 + pathname: P/PE/PETDANCE/HTML-Tagset-3.24.tar.gz + provides: + HTML::Tagset 3.24 + requirements: + ExtUtils::MakeMaker 6.46 + perl 5.010001 + HTTP-Body-1.23 + pathname: G/GE/GETTY/HTTP-Body-1.23.tar.gz + provides: + HTTP::Body 1.23 + HTTP::Body::MultiPart 1.23 + HTTP::Body::OctetStream 1.23 + HTTP::Body::UrlEncoded 1.23 + HTTP::Body::XForms 1.23 + HTTP::Body::XFormsMultipart 1.23 + requirements: + Carp 0 + Digest::MD5 0 + ExtUtils::MakeMaker 0 + File::Temp 0.14 + HTTP::Headers 0 + IO::File 1.14 + HTTP-Cookies-6.11 + pathname: O/OA/OALDERS/HTTP-Cookies-6.11.tar.gz + provides: + HTTP::Cookies 6.11 + HTTP::Cookies::Microsoft 6.11 + HTTP::Cookies::Netscape 6.11 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + HTTP::Date 6 + HTTP::Headers::Util 6 + HTTP::Request 0 + locale 0 + perl 5.008001 + strict 0 + HTTP-Date-6.06 + pathname: O/OA/OALDERS/HTTP-Date-6.06.tar.gz + provides: + HTTP::Date 6.06 + requirements: + Exporter 0 + ExtUtils::MakeMaker 0 + Time::Local 1.28 + Time::Zone 0 + perl 5.006002 + strict 0 + HTTP-Entity-Parser-0.25 + pathname: K/KA/KAZEBURO/HTTP-Entity-Parser-0.25.tar.gz + provides: + HTTP::Entity::Parser 0.25 + HTTP::Entity::Parser::JSON undef + HTTP::Entity::Parser::MultiPart undef + HTTP::Entity::Parser::OctetStream undef + HTTP::Entity::Parser::UrlEncoded undef + requirements: + Encode 0 + File::Temp 0 + HTTP::MultiPartParser 0 + Hash::MultiValue 0 + JSON::MaybeXS 1.003007 + Module::Build::Tiny 0.035 + Module::Load 0 + Stream::Buffered 0 + WWW::Form::UrlEncoded 0.23 + perl 5.008001 + HTTP-Headers-Fast-0.22 + pathname: T/TO/TOKUHIROM/HTTP-Headers-Fast-0.22.tar.gz + provides: + HTTP::Headers::Fast 0.22 + requirements: + HTTP::Date 0 + Module::Build::Tiny 0.035 + perl 5.008001 + HTTP-Message-7.00 + pathname: O/OA/OALDERS/HTTP-Message-7.00.tar.gz + provides: + HTTP::Config 7.00 + HTTP::Headers 7.00 + HTTP::Headers::Auth 7.00 + HTTP::Headers::ETag 7.00 + HTTP::Headers::Util 7.00 + HTTP::Message 7.00 + HTTP::Request 7.00 + HTTP::Request::Common 7.00 + HTTP::Response 7.00 + HTTP::Status 7.00 + requirements: + Carp 0 + Clone 0.46 + Compress::Raw::Bzip2 0 + Compress::Raw::Zlib 2.062 + Encode 3.01 + Encode::Locale 1 + Exporter 5.57 + ExtUtils::MakeMaker 0 + File::Spec 0 + HTTP::Date 6 + IO::Compress::Bzip2 2.021 + IO::Compress::Deflate 0 + IO::Compress::Gzip 0 + IO::HTML 0 + IO::Uncompress::Inflate 0 + IO::Uncompress::RawInflate 0 + LWP::MediaTypes 6 + MIME::Base64 2.1 + MIME::QuotedPrint 0 + URI 1.10 + parent 0 + perl 5.008001 + strict 0 + warnings 0 + HTTP-MultiPartParser-0.02 + pathname: C/CH/CHANSEN/HTTP-MultiPartParser-0.02.tar.gz + provides: + HTTP::MultiPartParser 0.02 + requirements: + Carp 0 + ExtUtils::MakeMaker 6.59 + Scalar::Util 0 + Test::Deep 0 + Test::More 0.88 + perl 5.008001 + HTTP-Negotiate-6.01 + pathname: G/GA/GAAS/HTTP-Negotiate-6.01.tar.gz + provides: + HTTP::Negotiate 6.01 + requirements: + ExtUtils::MakeMaker 0 + HTTP::Headers 6 + perl 5.008001 + HTTP-Thin-0.006 + pathname: P/PE/PERIGRIN/HTTP-Thin-0.006.tar.gz + provides: + HTTP::Thin 0.006 + requirements: + Class::Method::Modifiers 0 + ExtUtils::MakeMaker 6.30 + HTTP::Response 0 + HTTP::Tiny 0 + Hash::MultiValue 0 + Safe::Isa 0 + parent 0 + warnings 0 + HTTP-Tiny-0.090 + pathname: H/HA/HAARG/HTTP-Tiny-0.090.tar.gz + provides: + HTTP::Tiny 0.090 + requirements: + Carp 0 + ExtUtils::MakeMaker 6.17 + Fcntl 0 + IO::Socket 0 + MIME::Base64 0 + Socket 0 + Time::Local 0 + bytes 0 + perl 5.006 + strict 0 + warnings 0 + Hash-Merge-0.302 + pathname: H/HE/HERMES/Hash-Merge-0.302.tar.gz + provides: + Hash::Merge 0.302 + requirements: + Clone::Choose 0.008 + ExtUtils::MakeMaker 6.64 + Scalar::Util 0 + perl 5.008001 + Hash-Merge-Simple-0.052 + pathname: H/HA/HAARG/Hash-Merge-Simple-0.052.tar.gz + provides: + Hash::Merge::Simple 0.052 + requirements: + Clone 0 + ExtUtils::MakeMaker 0 + Storable 0 + perl 5.006000 + Hash-MoreUtils-0.06 + pathname: R/RE/REHSACK/Hash-MoreUtils-0.06.tar.gz + provides: + Hash::MoreUtils 0.06 + requirements: + ExtUtils::MakeMaker 0 + perl 5.008001 + Hash-MultiValue-0.16 + pathname: A/AR/ARISTOTLE/Hash-MultiValue-0.16.tar.gz + provides: + Hash::MultiValue 0.16 + requirements: + ExtUtils::MakeMaker 0 + perl 5.008001 + IO-1.55 + pathname: T/TO/TODDR/IO-1.55.tar.gz + provides: + IO 1.55 + IO::Dir 1.55 + IO::File 1.55 + IO::Handle 1.55 + IO::Pipe 1.55 + IO::Pipe::End 1.55 + IO::Poll 1.55 + IO::Seekable 1.55 + IO::Select 1.55 + IO::Socket 1.55 + IO::Socket::INET 1.55 + IO::Socket::UNIX 1.55 + requirements: + ExtUtils::MakeMaker 0 + File::Temp 0.15 + Test::More 0 + IO-Compress-2.213 + pathname: P/PM/PMQS/IO-Compress-2.213.tar.gz + provides: + Compress::Zlib 2.213 + File::GlobMapper 1.001 + IO::Compress 2.213 + IO::Compress::Adapter::Bzip2 2.213 + IO::Compress::Adapter::Deflate 2.213 + IO::Compress::Adapter::Identity 2.213 + IO::Compress::Base 2.213 + IO::Compress::Base::Common 2.213 + IO::Compress::Bzip2 2.213 + IO::Compress::Deflate 2.213 + IO::Compress::Gzip 2.213 + IO::Compress::Gzip::Constants 2.213 + IO::Compress::RawDeflate 2.213 + IO::Compress::Zip 2.213 + IO::Compress::Zip::Constants 2.213 + IO::Compress::Zlib::Constants 2.213 + IO::Compress::Zlib::Extra 2.213 + IO::Uncompress::Adapter::Bunzip2 2.213 + IO::Uncompress::Adapter::Identity 2.213 + IO::Uncompress::Adapter::Inflate 2.213 + IO::Uncompress::AnyInflate 2.213 + IO::Uncompress::AnyUncompress 2.213 + IO::Uncompress::Base 2.213 + IO::Uncompress::Bunzip2 2.213 + IO::Uncompress::Gunzip 2.213 + IO::Uncompress::Inflate 2.213 + IO::Uncompress::RawInflate 2.213 + IO::Uncompress::Unzip 2.213 + U64 2.213 + Zlib::OldDeflate 2.213 + Zlib::OldInflate 2.213 + requirements: + Compress::Raw::Bzip2 2.213 + Compress::Raw::Zlib 2.213 + Encode 0 + ExtUtils::MakeMaker 0 + Scalar::Util 0 + Time::Local 0 + IO-File-AtomicChange-0.08 + pathname: H/HI/HIROSE/IO-File-AtomicChange-0.08.tar.gz + provides: + IO::File::AtomicChange 0.08 + requirements: + File::Copy 0 + File::Temp 0 + IO 1.39 + Module::Build::Tiny 0.039 + POSIX 0 + Path::Class 0 + Time::HiRes 0 + perl 5.008005 + IO-HTML-1.004 + pathname: C/CJ/CJM/IO-HTML-1.004.tar.gz + provides: + IO::HTML 1.004 + requirements: + Carp 0 + Encode 2.10 + Exporter 5.57 + ExtUtils::MakeMaker 0 + perl 5.008 + IO-Prompt-Tiny-0.003 + pathname: D/DA/DAGOLDEN/IO-Prompt-Tiny-0.003.tar.gz + provides: + IO::Prompt::Tiny 0.003 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 6.17 + perl 5.006 + strict 0 + warnings 0 + IO-Socket-IP-0.43 + pathname: P/PE/PEVANS/IO-Socket-IP-0.43.tar.gz + provides: + IO::Socket::IP 0.43 + requirements: + IO::Socket 0 + Module::Build 0.4004 + Socket 1.97 + perl 5.014 + IO-Socket-SSL-2.089 + pathname: S/SU/SULLR/IO-Socket-SSL-2.089.tar.gz + provides: + IO::Socket::SSL 2.089 + IO::Socket::SSL::Intercept 2.056 + IO::Socket::SSL::OCSP_Cache 2.089 + IO::Socket::SSL::OCSP_Resolver 2.089 + IO::Socket::SSL::PublicSuffix undef + IO::Socket::SSL::SSL_Context 2.089 + IO::Socket::SSL::SSL_HANDLE 2.089 + IO::Socket::SSL::Session_Cache 2.089 + IO::Socket::SSL::Trace 2.089 + IO::Socket::SSL::Utils 2.015 + requirements: + ExtUtils::MakeMaker 0 + Net::SSLeay 1.46 + Scalar::Util 0 + IPC-Run3-0.049 + pathname: R/RJ/RJBS/IPC-Run3-0.049.tar.gz + provides: + IPC::Run3 0.049 + requirements: + ExtUtils::MakeMaker 0 + Test::More 0.31 + Time::HiRes 0 + IPC-System-Simple-1.30 + pathname: J/JK/JKEENAN/IPC-System-Simple-1.30.tar.gz + provides: + IPC::System::Simple 1.30 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + List::Util 0 + POSIX 0 + Scalar::Util 0 + constant 0 + perl 5.006 + re 0 + strict 0 + warnings 0 + Import-Into-1.002005 + pathname: H/HA/HAARG/Import-Into-1.002005.tar.gz + provides: + Import::Into 1.002005 + requirements: + ExtUtils::MakeMaker 0 + Module::Runtime 0 + perl 5.006 + strict 0 + warnings 0 + JSON-4.10 + pathname: I/IS/ISHIGAKI/JSON-4.10.tar.gz + provides: + JSON 4.10 + JSON::Backend::PP 4.10 + requirements: + ExtUtils::MakeMaker 0 + Test::More 0 + JSON-MaybeXS-1.004008 + pathname: E/ET/ETHER/JSON-MaybeXS-1.004008.tar.gz + provides: + JSON::MaybeXS 1.004008 + requirements: + Carp 0 + Cpanel::JSON::XS 2.3310 + ExtUtils::MakeMaker 0 + JSON::PP 2.27300 + Scalar::Util 0 + perl 5.006 + JSON-Validator-5.15 + pathname: J/JH/JHTHORSEN/JSON-Validator-5.15.tar.gz + provides: + JSON::Validator 5.15 + JSON::Validator::Error undef + JSON::Validator::Formats undef + JSON::Validator::Joi undef + JSON::Validator::Schema undef + JSON::Validator::Schema::Draft201909 undef + JSON::Validator::Schema::Draft4 undef + JSON::Validator::Schema::Draft6 undef + JSON::Validator::Schema::Draft7 undef + JSON::Validator::Schema::OpenAPIv2 undef + JSON::Validator::Schema::OpenAPIv3 undef + JSON::Validator::Store undef + JSON::Validator::URI undef + JSON::Validator::Util undef + requirements: + ExtUtils::MakeMaker 0 + List::Util 1.45 + Mojolicious 7.28 + YAML::XS 0.67 + perl 5.016 + JSON-XS-4.03 + pathname: M/ML/MLEHMANN/JSON-XS-4.03.tar.gz + provides: + JSON::XS 4.03 + requirements: + Canary::Stability 0 + ExtUtils::MakeMaker 6.52 + Types::Serialiser 0 + common::sense 0 + LWP-MediaTypes-6.04 + pathname: O/OA/OALDERS/LWP-MediaTypes-6.04.tar.gz + provides: + LWP::MediaTypes 6.04 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + File::Basename 0 + Scalar::Util 0 + perl 5.006002 + strict 0 + LWP-Protocol-https-6.14 + pathname: O/OA/OALDERS/LWP-Protocol-https-6.14.tar.gz + provides: + LWP::Protocol::https 6.14 + LWP::Protocol::https::Socket 6.14 + requirements: + ExtUtils::MakeMaker 0 + IO::Socket::SSL 1.970 + LWP::Protocol::http 0 + LWP::UserAgent 6.06 + Net::HTTPS 6 + base 0 + perl 5.008001 + strict 0 + Lingua-EN-Inflect-1.905 + pathname: D/DC/DCONWAY/Lingua-EN-Inflect-1.905.tar.gz + provides: + Lingua::EN::Inflect 1.905 + requirements: + ExtUtils::MakeMaker 0 + Test::More 0 + List-Compare-0.55 + pathname: J/JK/JKEENAN/List-Compare-0.55.tar.gz + provides: + List::Compare 0.55 + List::Compare::Accelerated 0.55 + List::Compare::Base::_Auxiliary 0.55 + List::Compare::Base::_Engine 0.55 + List::Compare::Functional 0.55 + List::Compare::Multiple 0.55 + List::Compare::Multiple::Accelerated 0.55 + requirements: + ExtUtils::MakeMaker 0 + List-MoreUtils-0.430 + pathname: R/RE/REHSACK/List-MoreUtils-0.430.tar.gz + provides: + List::MoreUtils 0.430 + List::MoreUtils::PP 0.430 + requirements: + Exporter::Tiny 0.038 + ExtUtils::MakeMaker 0 + List::MoreUtils::XS 0.430 + List-MoreUtils-XS-0.430 + pathname: R/RE/REHSACK/List-MoreUtils-XS-0.430.tar.gz + provides: + List::MoreUtils::XS 0.430 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Copy 0 + File::Path 0 + File::Spec 0 + IPC::Cmd 0 + XSLoader 0.22 + base 0 + List-SomeUtils-0.59 + pathname: D/DR/DROLSKY/List-SomeUtils-0.59.tar.gz + provides: + List::SomeUtils 0.59 + List::SomeUtils::PP 0.59 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + List::SomeUtils::XS 0.54 + List::Util 0 + Module::Implementation 0.04 + Text::ParseWords 0 + perl 5.006 + strict 0 + vars 0 + warnings 0 + List-SomeUtils-XS-0.58 + pathname: D/DR/DROLSKY/List-SomeUtils-XS-0.58.tar.gz + provides: + List::SomeUtils::XS 0.58 + requirements: + ExtUtils::MakeMaker 0 + XSLoader 0 + strict 0 + warnings 0 + Log-Any-1.717 + pathname: P/PR/PREACTION/Log-Any-1.717.tar.gz + provides: + Log::Any 1.717 + Log::Any::Adapter 1.717 + Log::Any::Adapter::Base 1.717 + Log::Any::Adapter::Capture 1.717 + Log::Any::Adapter::File 1.717 + Log::Any::Adapter::Multiplex 1.717 + Log::Any::Adapter::Null 1.717 + Log::Any::Adapter::Stderr 1.717 + Log::Any::Adapter::Stdout 1.717 + Log::Any::Adapter::Syslog 1.717 + Log::Any::Adapter::Test 1.717 + Log::Any::Adapter::Util 1.717 + Log::Any::Manager 1.717 + Log::Any::Proxy 1.717 + Log::Any::Proxy::Null 1.717 + Log::Any::Proxy::Test 1.717 + Log::Any::Proxy::WithStackTrace 1.717 + Log::Any::Test 1.717 + requirements: + ExtUtils::MakeMaker 0 + Log-Any-Adapter-Log4perl-0.09 + pathname: P/PR/PREACTION/Log-Any-Adapter-Log4perl-0.09.tar.gz + provides: + Log::Any::Adapter::Log4perl 0.09 + requirements: + ExtUtils::MakeMaker 6.17 + Log::Any::Adapter::Base 0 + Log::Any::Adapter::Util 1.03 + Log::Log4perl 1.32 + base 0 + perl 5.006 + strict 0 + warnings 0 + Log-Contextual-0.009001 + pathname: H/HA/HAARG/Log-Contextual-0.009001.tar.gz + provides: + Log::Contextual 0.009001 + Log::Contextual::Easy::Default 0.009001 + Log::Contextual::Easy::Package 0.009001 + Log::Contextual::Role::Router 0.009001 + Log::Contextual::Role::Router::HasLogger 0.009001 + Log::Contextual::Role::Router::SetLogger 0.009001 + Log::Contextual::Role::Router::WithLogger 0.009001 + Log::Contextual::Router 0.009001 + Log::Contextual::SimpleLogger 0.009001 + Log::Contextual::TeeLogger 0.009001 + Log::Contextual::WarnLogger 0.009001 + requirements: + Carp 0 + Data::Dumper::Concise 0 + ExtUtils::MakeMaker 0 + Moo 1.003000 + Scalar::Util 0 + perl 5.008001 + Log-Dispatch-2.71 + pathname: D/DR/DROLSKY/Log-Dispatch-2.71.tar.gz + provides: + Log::Dispatch 2.71 + Log::Dispatch::ApacheLog 2.71 + Log::Dispatch::Base 2.71 + Log::Dispatch::Code 2.71 + Log::Dispatch::Email 2.71 + Log::Dispatch::Email::MIMELite 2.71 + Log::Dispatch::Email::MailSend 2.71 + Log::Dispatch::Email::MailSender 2.71 + Log::Dispatch::Email::MailSendmail 2.71 + Log::Dispatch::File 2.71 + Log::Dispatch::File::Locked 2.71 + Log::Dispatch::Handle 2.71 + Log::Dispatch::Null 2.71 + Log::Dispatch::Output 2.71 + Log::Dispatch::Screen 2.71 + Log::Dispatch::Syslog 2.71 + Log::Dispatch::Types 2.71 + Log::Dispatch::Vars 2.71 + requirements: + Carp 0 + Devel::GlobalDestruction 0 + Dist::CheckConflicts 0.02 + Encode 0 + Exporter 0 + ExtUtils::MakeMaker 0 + Fcntl 0 + IO::Handle 0 + Module::Runtime 0 + Params::ValidationCompiler 0 + Scalar::Util 0 + Specio 0.32 + Specio::Declare 0 + Specio::Exporter 0 + Specio::Library::Builtins 0 + Specio::Library::Numeric 0 + Specio::Library::String 0 + Sys::Syslog 0.28 + Try::Tiny 0 + base 0 + namespace::autoclean 0 + parent 0 + perl 5.006 + strict 0 + warnings 0 + Log-Log4perl-1.57 + pathname: E/ET/ETJ/Log-Log4perl-1.57.tar.gz + provides: + L4pResurrectable 0.01 + Log::Log4perl 1.57 + Log::Log4perl::Appender undef + Log::Log4perl::Appender::Buffer 1.53 + Log::Log4perl::Appender::DBI undef + Log::Log4perl::Appender::File undef + Log::Log4perl::Appender::Limit 1.53 + Log::Log4perl::Appender::RRDs undef + Log::Log4perl::Appender::Screen undef + Log::Log4perl::Appender::ScreenColoredLevels undef + Log::Log4perl::Appender::Socket undef + Log::Log4perl::Appender::String undef + Log::Log4perl::Appender::Synchronized 1.53 + Log::Log4perl::Appender::TestArrayBuffer undef + Log::Log4perl::Appender::TestBuffer undef + Log::Log4perl::Appender::TestFileCreeper undef + Log::Log4perl::Catalyst 1.53 + Log::Log4perl::Config undef + Log::Log4perl::Config::BaseConfigurator undef + Log::Log4perl::Config::DOMConfigurator 0.03 + Log::Log4perl::Config::PropertyConfigurator undef + Log::Log4perl::Config::Watch undef + Log::Log4perl::DateFormat undef + Log::Log4perl::Filter undef + Log::Log4perl::Filter::Boolean undef + Log::Log4perl::Filter::LevelMatch undef + Log::Log4perl::Filter::LevelRange undef + Log::Log4perl::Filter::MDC undef + Log::Log4perl::Filter::StringMatch undef + Log::Log4perl::InternalDebug undef + Log::Log4perl::JavaMap undef + Log::Log4perl::JavaMap::ConsoleAppender undef + Log::Log4perl::JavaMap::FileAppender undef + Log::Log4perl::JavaMap::JDBCAppender undef + Log::Log4perl::JavaMap::NTEventLogAppender undef + Log::Log4perl::JavaMap::RollingFileAppender undef + Log::Log4perl::JavaMap::SyslogAppender undef + Log::Log4perl::JavaMap::TestBuffer undef + Log::Log4perl::Layout undef + Log::Log4perl::Layout::NoopLayout undef + Log::Log4perl::Layout::PatternLayout undef + Log::Log4perl::Layout::PatternLayout::Multiline undef + Log::Log4perl::Layout::SimpleLayout undef + Log::Log4perl::Level undef + Log::Log4perl::Logger undef + Log::Log4perl::MDC undef + Log::Log4perl::NDC undef + Log::Log4perl::Resurrector undef + Log::Log4perl::Util undef + Log::Log4perl::Util::Semaphore undef + Log::Log4perl::Util::TimeTracker undef + requirements: + ExtUtils::MakeMaker 0 + File::Path 2.07 + File::Spec 0.82 + perl 5.006 + Log-Log4perl-Layout-JSON-0.61 + pathname: M/MS/MSCHOUT/Log-Log4perl-Layout-JSON-0.61.tar.gz + provides: + Log::Log4perl::Layout::JSON 0.61 + requirements: + Carp 0 + Class::Tiny 0 + ExtUtils::MakeMaker 0 + JSON::MaybeXS 0 + Log::Log4perl 0 + Log::Log4perl::Layout 0 + Log::Log4perl::Layout::PatternLayout 0 + Log::Log4perl::Level 0 + Scalar::Util 0 + parent 0 + perl 5.010 + strict 0 + warnings 0 + MCE-1.901 + pathname: M/MA/MARIOROY/MCE-1.901.tar.gz + provides: + MCE 1.901 + MCE::Candy 1.901 + MCE::Channel 1.901 + MCE::Channel::Mutex 1.901 + MCE::Channel::MutexFast 1.901 + MCE::Channel::Simple 1.901 + MCE::Channel::SimpleFast 1.901 + MCE::Channel::Threads 1.901 + MCE::Channel::ThreadsFast 1.901 + MCE::Child 1.901 + MCE::Core 1.901 + MCE::Core::Input::Generator 1.901 + MCE::Core::Input::Handle 1.901 + MCE::Core::Input::Iterator 1.901 + MCE::Core::Input::Request 1.901 + MCE::Core::Input::Sequence 1.901 + MCE::Core::Manager 1.901 + MCE::Core::Validation 1.901 + MCE::Core::Worker 1.901 + MCE::Flow 1.901 + MCE::Grep 1.901 + MCE::Loop 1.901 + MCE::Map 1.901 + MCE::Mutex 1.901 + MCE::Mutex::Channel 1.901 + MCE::Mutex::Channel2 1.901 + MCE::Mutex::Flock 1.901 + MCE::Queue 1.901 + MCE::Relay 1.901 + MCE::Signal 1.901 + MCE::Step 1.901 + MCE::Stream 1.901 + MCE::Subs 1.901 + MCE::Util 1.901 + requirements: + Carp 0 + Errno 0 + ExtUtils::MakeMaker 0 + Fcntl 0 + File::Path 0 + Getopt::Long 0 + IO::Handle 0 + Scalar::Util 0 + Socket 0 + Storable 2.04 + Time::HiRes 0 + base 0 + bytes 0 + constant 0 + open 0 + perl 5.008001 + strict 0 + warnings 0 + MIME-Base32-1.303 + pathname: R/RE/REHSACK/MIME-Base32-1.303.tar.gz + provides: + MIME::Base32 1.303 + requirements: + Exporter 0 + ExtUtils::MakeMaker 0 + perl 5.008001 + utf8 0 + MIME-Charset-1.013.1 + pathname: N/NE/NEZUMI/MIME-Charset-1.013.1.tar.gz + provides: + MIME::Charset v1.13.1 + requirements: + CPAN 0 + Encode 1.98 + ExtUtils::MakeMaker 6.42 + Test::More 0 + perl 5.005 + MIME-Types-2.28 + pathname: M/MA/MARKOV/MIME-Types-2.28.tar.gz + provides: + MIME::Type 2.28 + MIME::Types 2.28 + MojoX::MIME::Types 2.28 + requirements: + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Spec 0 + List::Util 0 + Test::More 0.47 + MRO-Compat-0.15 + pathname: H/HA/HAARG/MRO-Compat-0.15.tar.gz + provides: + MRO::Compat 0.15 + requirements: + ExtUtils::MakeMaker 0 + perl 5.006 + MailTools-2.22 + pathname: M/MA/MARKOV/MailTools-2.22.tar.gz + provides: + Mail::Address 2.22 + Mail::Cap 2.22 + Mail::Field 2.22 + Mail::Field::AddrList 2.22 + Mail::Field::Date 2.22 + Mail::Field::Generic 2.22 + Mail::Filter 2.22 + Mail::Header 2.22 + Mail::Internet 2.22 + Mail::Mailer 2.22 + Mail::Mailer::qmail 2.22 + Mail::Mailer::rfc822 2.22 + Mail::Mailer::sendmail 2.22 + Mail::Mailer::smtp 2.22 + Mail::Mailer::smtp::pipe 2.22 + Mail::Mailer::smtps 2.22 + Mail::Mailer::smtps::pipe 2.22 + Mail::Mailer::testfile 2.22 + Mail::Mailer::testfile::pipe 2.22 + Mail::Send 2.22 + Mail::Util 2.22 + MailTools 2.22 + requirements: + Date::Format 0 + Date::Parse 0 + ExtUtils::MakeMaker 0 + IO::Handle 0 + Net::Domain 1.05 + Net::SMTP 1.28 + Test::More 0 + Math-BigInt-2.005003 + pathname: P/PJ/PJACKLAM/Math-BigInt-2.005003.tar.gz + provides: + Math::BigFloat 2.005003 + Math::BigInt 2.005003 + Math::BigInt::Calc 2.005003 + Math::BigInt::Lib 2.005003 + Math::BigRat 2.005003 + requirements: + Carp 1.22 + ExtUtils::MakeMaker 6.58 + Math::Complex 1.36 + Scalar::Util 0 + perl 5.006001 + MetaCPAN-Client-2.033000 + pathname: M/MI/MICKEY/MetaCPAN-Client-2.033000.tar.gz + provides: + MetaCPAN::Client 2.033000 + MetaCPAN::Client::Author 2.033000 + MetaCPAN::Client::Cover 2.033000 + MetaCPAN::Client::Distribution 2.033000 + MetaCPAN::Client::DownloadURL 2.033000 + MetaCPAN::Client::Favorite 2.033000 + MetaCPAN::Client::File 2.033000 + MetaCPAN::Client::Mirror 2.033000 + MetaCPAN::Client::Module 2.033000 + MetaCPAN::Client::Package 2.033000 + MetaCPAN::Client::Permission 2.033000 + MetaCPAN::Client::Pod 2.033000 + MetaCPAN::Client::Rating 2.033000 + MetaCPAN::Client::Release 2.033000 + MetaCPAN::Client::Request 2.033000 + MetaCPAN::Client::ResultSet 2.033000 + MetaCPAN::Client::Role::Entity 2.033000 + MetaCPAN::Client::Role::HasUA 2.033000 + MetaCPAN::Client::Scroll 2.033000 + MetaCPAN::Client::Types 2.033000 + requirements: + Carp 0 + ExtUtils::MakeMaker 7.1101 + HTTP::Tiny 0.056 + IO::Socket::SSL 1.42 + JSON::MaybeXS 0 + JSON::PP 0 + Moo 0 + Moo::Role 0 + Net::SSLeay 1.49 + Ref::Util 0 + Safe::Isa 0 + Type::Tiny 0 + URI::Escape 0 + perl 5.010 + strict 0 + warnings 0 + MetaCPAN-Moose-0.000003 + pathname: O/OA/OALDERS/MetaCPAN-Moose-0.000003.tar.gz + provides: + MetaCPAN::Moose 0.000003 + requirements: + ExtUtils::MakeMaker 0 + Import::Into 1.002005 + Moose 2.1605 + MooseX::StrictConstructor 0.19 + namespace::autoclean 0.28 + perl 5.006 + strict 0 + warnings 0 + MetaCPAN-Pod-HTML-0.004000 + pathname: H/HA/HAARG/MetaCPAN-Pod-HTML-0.004000.tar.gz + provides: + MetaCPAN::Pod::HTML 0.004000 + MetaCPAN::Pod::XHTML 0.004000 + Pod::Simple::Role::StripVerbatimIndent 0.004000 + Pod::Simple::Role::WithHighlightConfig 0.004000 + Pod::Simple::Role::XHTML::HTML5 0.004000 + Pod::Simple::Role::XHTML::RepairLinkEncoding 0.004000 + Pod::Simple::Role::XHTML::WithAccurateTargets 0.004000 + Pod::Simple::Role::XHTML::WithErrata 0.004000 + Pod::Simple::Role::XHTML::WithExtraTargets 0.004000 + Pod::Simple::Role::XHTML::WithHighlightConfig 0.004000 + Pod::Simple::Role::XHTML::WithLinkMappings 0.004000 + Pod::Simple::Role::XHTML::WithPostProcess 0.004000 + requirements: + ExtUtils::MakeMaker 0 + HTML::Entities 3.69 + Moo 2.003000 + Moo::Role 2.003000 + Pod::Simple::XHTML 3.45 + URL::Encode 0.03 + namespace::clean 0.27 + MetaCPAN-Role-1.00 + pathname: L/LL/LLAP/MetaCPAN-Role-1.00.tar.gz + provides: + MetaCPAN::Role 1.00 + MetaCPAN::Role::Fastly 1.00 + MetaCPAN::Role::Fastly::Catalyst 1.00 + requirements: + Carp 0 + CatalystX::Fastly::Role::Response 0.07 + ExtUtils::MakeMaker 0 + Moose::Role 0 + MooseX::Fastly::Role 0.04 + Net::Fastly 1.05 + Minion-10.31 + pathname: S/SR/SRI/Minion-10.31.tar.gz + provides: + LinkCheck undef + LinkCheck::Controller::Links undef + LinkCheck::Task::CheckLinks undef + Minion 10.31 + Minion::Backend undef + Minion::Backend::Pg undef + Minion::Command::minion undef + Minion::Command::minion::job undef + Minion::Command::minion::worker undef + Minion::Iterator undef + Minion::Job undef + Minion::Worker undef + Mojolicious::Plugin::Minion undef + Mojolicious::Plugin::Minion::Admin undef + requirements: + ExtUtils::MakeMaker 0 + Mojolicious 9.0 + YAML::XS 0.67 + perl 5.016 + Minion-Backend-SQLite-v5.0.7 + pathname: D/DB/DBOOK/Minion-Backend-SQLite-v5.0.7.tar.gz + provides: + Minion::Backend::SQLite v5.0.7 + requirements: + List::Util 0 + Minion 10.13 + Module::Build::Tiny 0.034 + Mojo::SQLite 3.000 + Mojolicious 7.49 + Sys::Hostname 0 + Time::HiRes 0 + perl 5.010001 + Mixin-Linewise-0.111 + pathname: R/RJ/RJBS/Mixin-Linewise-0.111.tar.gz + provides: + Mixin::Linewise 0.111 + Mixin::Linewise::Readers 0.111 + Mixin::Linewise::Writers 0.111 + requirements: + Carp 0 + ExtUtils::MakeMaker 6.78 + IO::File 0 + PerlIO::utf8_strict 0 + Sub::Exporter 0 + perl 5.012 + strict 0 + warnings 0 + Module-Build-0.4234 + pathname: L/LE/LEONT/Module-Build-0.4234.tar.gz + provides: + Module::Build 0.4234 + Module::Build::Base 0.4234 + Module::Build::Compat 0.4234 + Module::Build::Config 0.4234 + Module::Build::Cookbook 0.4234 + Module::Build::Dumper 0.4234 + Module::Build::Notes 0.4234 + Module::Build::PPMMaker 0.4234 + Module::Build::Platform::Default 0.4234 + Module::Build::Platform::MacOS 0.4234 + Module::Build::Platform::Unix 0.4234 + Module::Build::Platform::VMS 0.4234 + Module::Build::Platform::VOS 0.4234 + Module::Build::Platform::Windows 0.4234 + Module::Build::Platform::aix 0.4234 + Module::Build::Platform::cygwin 0.4234 + Module::Build::Platform::darwin 0.4234 + Module::Build::Platform::os2 0.4234 + Module::Build::PodParser 0.4234 + requirements: + CPAN::Meta 2.142060 + Cwd 0 + Data::Dumper 0 + ExtUtils::CBuilder 0.27 + ExtUtils::Install 0 + ExtUtils::Manifest 0 + ExtUtils::Mkbootstrap 0 + ExtUtils::ParseXS 2.21 + File::Basename 0 + File::Compare 0 + File::Copy 0 + File::Find 0 + File::Path 0 + File::Spec 0.82 + Getopt::Long 0 + Module::Metadata 1.000002 + Perl::OSType 1 + TAP::Harness 3.29 + Text::Abbrev 0 + Text::ParseWords 0 + perl 5.006001 + version 0.87 + Module-Build-Tiny-0.051 + pathname: L/LE/LEONT/Module-Build-Tiny-0.051.tar.gz + provides: + Module::Build::Tiny 0.051 + requirements: + CPAN::Meta 0 + DynaLoader 0 + Exporter 5.57 + ExtUtils::CBuilder 0 + ExtUtils::Config 0.003 + ExtUtils::Helpers 0.020 + ExtUtils::Install 0 + ExtUtils::InstallPaths 0.002 + ExtUtils::ParseXS 0 + File::Basename 0 + File::Find 0 + File::Path 0 + File::Spec::Functions 0 + Getopt::Long 2.36 + JSON::PP 2 + Pod::Man 0 + TAP::Harness::Env 0 + perl 5.006 + strict 0 + warnings 0 + Module-CPANfile-1.1004 + pathname: M/MI/MIYAGAWA/Module-CPANfile-1.1004.tar.gz + provides: + Module::CPANfile 1.1004 + Module::CPANfile::Environment undef + Module::CPANfile::Prereq undef + Module::CPANfile::Prereqs undef + Module::CPANfile::Requirement undef + requirements: + CPAN::Meta 2.12091 + CPAN::Meta::Prereqs 2.12091 + ExtUtils::MakeMaker 0 + parent 0 + Module-Faker-0.017 + pathname: R/RJ/RJBS/Module-Faker-0.017.tar.gz + provides: + Module::Faker 0.017 + Module::Faker::Appendix 0.017 + Module::Faker::Dist 0.017 + Module::Faker::File 0.017 + Module::Faker::Heavy 0.017 + Module::Faker::Module 0.017 + Module::Faker::Package 0.017 + requirements: + Archive::Any::Create 0 + CPAN::DistnameInfo 0 + CPAN::Meta 2.130880 + CPAN::Meta::Requirements 0 + Carp 0 + Encode 0 + ExtUtils::MakeMaker 0 + File::Next 0 + File::Path 0 + File::Temp 0 + Moose 0.33 + Moose::Role 0 + Moose::Util::TypeConstraints 0 + Parse::CPAN::Meta 1.4401 + Path::Class 0.06 + Text::Template 0 + strict 0 + warnings 0 + Module-Find-0.17 + pathname: C/CR/CRENZ/Module-Find-0.17.tar.gz + provides: + Module::Find 0.17 + requirements: + ExtUtils::MakeMaker 0 + File::Find 0 + File::Spec 0 + Test::More 0 + perl 5.008001 + Module-Implementation-0.09 + pathname: D/DR/DROLSKY/Module-Implementation-0.09.tar.gz + provides: + Module::Implementation 0.09 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + Module::Runtime 0.012 + Try::Tiny 0 + strict 0 + warnings 0 + Module-Load-Conditional-0.74 + pathname: B/BI/BINGOS/Module-Load-Conditional-0.74.tar.gz + provides: + Module::Load::Conditional 0.74 + requirements: + ExtUtils::MakeMaker 0 + Locale::Maketext::Simple 0 + Module::CoreList 2.22 + Module::Load 0.28 + Module::Metadata 1.000005 + Params::Check 0 + Test::More 0 + version 0.69 + Module-Metadata-1.000038 + pathname: E/ET/ETHER/Module-Metadata-1.000038.tar.gz + provides: + Module::Metadata 1.000038 + requirements: + Carp 0 + Encode 0 + ExtUtils::MakeMaker 0 + Fcntl 0 + File::Find 0 + File::Spec 0 + perl 5.006 + strict 0 + version 0.87 + warnings 0 + Module-Pluggable-6.3 + pathname: S/SI/SIMONW/Module-Pluggable-6.3.tar.gz + provides: + Devel::InnerPackage 0.4 + Module::Pluggable 6.3 + Module::Pluggable::Object 5.2 + requirements: + Exporter 5.57 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Find 0 + File::Spec 3.00 + File::Spec::Functions 0 + Scalar::Util 0 + if 0 + perl 5.006 + strict 0 + Module-Runtime-0.018 + pathname: H/HA/HAARG/Module-Runtime-0.018.tar.gz + provides: + Module::Runtime 0.018 + requirements: + ExtUtils::MakeMaker 0 + perl 5.006000 + Module-Runtime-Conflicts-0.003 + pathname: E/ET/ETHER/Module-Runtime-Conflicts-0.003.tar.gz + provides: + Module::Runtime::Conflicts 0.003 + requirements: + Dist::CheckConflicts 0 + ExtUtils::MakeMaker 0 + Module::Runtime 0 + perl 5.006 + strict 0 + warnings 0 + Mojo-Pg-4.27 + pathname: S/SR/SRI/Mojo-Pg-4.27.tar.gz + provides: + Mojo::Pg 4.27 + Mojo::Pg::Database undef + Mojo::Pg::Migrations undef + Mojo::Pg::PubSub undef + Mojo::Pg::Results undef + Mojo::Pg::Transaction undef + requirements: + DBD::Pg 3.007004 + ExtUtils::MakeMaker 0 + Mojolicious 8.50 + SQL::Abstract::Pg 1.0 + perl 5.016 + Mojo-SQLite-3.009 + pathname: D/DB/DBOOK/Mojo-SQLite-3.009.tar.gz + provides: + Mojo::SQLite 3.009 + Mojo::SQLite::Database 3.009 + Mojo::SQLite::Migrations 3.009 + Mojo::SQLite::PubSub 3.009 + Mojo::SQLite::Results 3.009 + Mojo::SQLite::Transaction 3.009 + requirements: + Carp 0 + DBD::SQLite 1.68 + DBI 1.627 + File::Spec::Functions 0 + File::Temp 0 + Module::Build::Tiny 0.034 + Mojolicious 8.03 + SQL::Abstract::Pg 1.0 + Scalar::Util 0 + URI 1.69 + URI::db 0.15 + URI::file 4.21 + perl 5.010001 + Mojolicious-9.39 + pathname: S/SR/SRI/Mojolicious-9.39.tar.gz + provides: + Mojo undef + Mojo::Asset undef + Mojo::Asset::File undef + Mojo::Asset::Memory undef + Mojo::Base undef + Mojo::BaseUtil undef + Mojo::ByteStream undef + Mojo::Cache undef + Mojo::Collection undef + Mojo::Content undef + Mojo::Content::MultiPart undef + Mojo::Content::Single undef + Mojo::Cookie undef + Mojo::Cookie::Request undef + Mojo::Cookie::Response undef + Mojo::DOM undef + Mojo::DOM::CSS undef + Mojo::DOM::HTML undef + Mojo::Date undef + Mojo::DynamicMethods undef + Mojo::EventEmitter undef + Mojo::Exception undef + Mojo::File undef + Mojo::Headers undef + Mojo::HelloWorld undef + Mojo::Home undef + Mojo::IOLoop undef + Mojo::IOLoop::Client undef + Mojo::IOLoop::Server undef + Mojo::IOLoop::Stream undef + Mojo::IOLoop::Subprocess undef + Mojo::IOLoop::TLS undef + Mojo::JSON undef + Mojo::JSON::Pointer undef + Mojo::Loader undef + Mojo::Log undef + Mojo::Message undef + Mojo::Message::Request undef + Mojo::Message::Response undef + Mojo::Parameters undef + Mojo::Path undef + Mojo::Promise undef + Mojo::Reactor undef + Mojo::Reactor::EV undef + Mojo::Reactor::Poll undef + Mojo::Server undef + Mojo::Server::CGI undef + Mojo::Server::Daemon undef + Mojo::Server::Hypnotoad undef + Mojo::Server::Morbo undef + Mojo::Server::Morbo::Backend undef + Mojo::Server::Morbo::Backend::Poll undef + Mojo::Server::PSGI undef + Mojo::Server::Prefork undef + Mojo::Template undef + Mojo::Transaction undef + Mojo::Transaction::HTTP undef + Mojo::Transaction::WebSocket undef + Mojo::URL undef + Mojo::Upload undef + Mojo::UserAgent undef + Mojo::UserAgent::CookieJar undef + Mojo::UserAgent::Proxy undef + Mojo::UserAgent::Server undef + Mojo::UserAgent::Transactor undef + Mojo::Util undef + Mojo::WebSocket undef + Mojolicious 9.39 + Mojolicious::Command undef + Mojolicious::Command::Author::cpanify undef + Mojolicious::Command::Author::generate undef + Mojolicious::Command::Author::generate::app undef + Mojolicious::Command::Author::generate::dockerfile undef + Mojolicious::Command::Author::generate::lite_app undef + Mojolicious::Command::Author::generate::makefile undef + Mojolicious::Command::Author::generate::plugin undef + Mojolicious::Command::Author::inflate undef + Mojolicious::Command::cgi undef + Mojolicious::Command::daemon undef + Mojolicious::Command::eval undef + Mojolicious::Command::get undef + Mojolicious::Command::prefork undef + Mojolicious::Command::psgi undef + Mojolicious::Command::routes undef + Mojolicious::Command::version undef + Mojolicious::Commands undef + Mojolicious::Controller undef + Mojolicious::Lite undef + Mojolicious::Plugin undef + Mojolicious::Plugin::Config undef + Mojolicious::Plugin::DefaultHelpers undef + Mojolicious::Plugin::EPLRenderer undef + Mojolicious::Plugin::EPRenderer undef + Mojolicious::Plugin::HeaderCondition undef + Mojolicious::Plugin::JSONConfig undef + Mojolicious::Plugin::Mount undef + Mojolicious::Plugin::NotYAMLConfig undef + Mojolicious::Plugin::TagHelpers undef + Mojolicious::Plugins undef + Mojolicious::Renderer undef + Mojolicious::Routes undef + Mojolicious::Routes::Match undef + Mojolicious::Routes::Pattern undef + Mojolicious::Routes::Route undef + Mojolicious::Sessions undef + Mojolicious::Static undef + Mojolicious::Types undef + Mojolicious::Validator undef + Mojolicious::Validator::Validation undef + Test::Mojo undef + ojo undef + requirements: + ExtUtils::MakeMaker 0 + IO::Socket::IP 0.37 + Sub::Util 1.41 + perl 5.016 + Mojolicious-Plugin-MountPSGI-0.15 + pathname: J/JB/JBERGER/Mojolicious-Plugin-MountPSGI-0.15.tar.gz + provides: + Mojolicious::Plugin::MountPSGI 0.15 + Mojolicious::Plugin::MountPSGI::Proxy undef + requirements: + ExtUtils::MakeMaker 0 + Mojolicious 7.70 + Plack 0 + Mojolicious-Plugin-OpenAPI-5.11 + pathname: J/JH/JHTHORSEN/Mojolicious-Plugin-OpenAPI-5.11.tar.gz + provides: + Mojolicious::Plugin::OpenAPI 5.11 + Mojolicious::Plugin::OpenAPI::Cors undef + Mojolicious::Plugin::OpenAPI::Parameters undef + Mojolicious::Plugin::OpenAPI::Security undef + Mojolicious::Plugin::OpenAPI::SpecRenderer undef + requirements: + ExtUtils::MakeMaker 0 + JSON::Validator 5.13 + Mojolicious 9.00 + perl 5.016 + Mojolicious-Plugin-Web-Auth-0.17 + pathname: H/HA/HAYAJO/Mojolicious-Plugin-Web-Auth-0.17.tar.gz + provides: + Mojolicious::Plugin::Web::Auth 0.17 + Mojolicious::Plugin::Web::Auth::Base undef + Mojolicious::Plugin::Web::Auth::OAuth undef + Mojolicious::Plugin::Web::Auth::OAuth2 undef + Mojolicious::Plugin::Web::Auth::Site::Dropbox undef + Mojolicious::Plugin::Web::Auth::Site::Facebook undef + Mojolicious::Plugin::Web::Auth::Site::Github undef + Mojolicious::Plugin::Web::Auth::Site::Google undef + Mojolicious::Plugin::Web::Auth::Site::Instagram undef + Mojolicious::Plugin::Web::Auth::Site::Twitter undef + Mojolicious::Plugin::Web::Auth::Site::Yandex undef + requirements: + IO::Socket::SSL 1.77 + Module::Build::Tiny 0.035 + Mojolicious 7.13 + Net::OAuth 0.28 + perl 5.010001 + Moo-2.005005 + pathname: H/HA/HAARG/Moo-2.005005.tar.gz + provides: + Method::Generate::Accessor undef + Method::Generate::BuildAll undef + Method::Generate::Constructor undef + Method::Generate::DemolishAll undef + Moo 2.005005 + Moo::HandleMoose undef + Moo::HandleMoose::FakeConstructor undef + Moo::HandleMoose::FakeMetaClass undef + Moo::HandleMoose::_TypeMap undef + Moo::Object undef + Moo::Role 2.005005 + Moo::_Utils undef + Moo::sification undef + oo undef + requirements: + Carp 0 + Class::Method::Modifiers 1.10 + Exporter 0 + ExtUtils::MakeMaker 0 + Role::Tiny 2.002003 + Scalar::Util 1.00 + Sub::Defer 2.006006 + Sub::Quote 2.006006 + perl 5.006 + MooX-Aliases-0.001006 + pathname: H/HA/HAARG/MooX-Aliases-0.001006.tar.gz + provides: + MooX::Aliases 0.001006 + requirements: + Class::Method::Modifiers 1.05 + Moo 1.001 + perl 5.006 + strictures 1 + MooX-Locale-Passthrough-0.001 + pathname: R/RE/REHSACK/MooX-Locale-Passthrough-0.001.tar.gz + provides: + MooX::Locale::Passthrough 0.001 + requirements: + ExtUtils::MakeMaker 0 + Moo 1.003 + perl 5.008001 + MooX-Options-4.103 + pathname: R/RE/REHSACK/MooX-Options-4.103.tar.gz + provides: + MooX::Options 4.103 + MooX::Options::Descriptive 4.103 + MooX::Options::Descriptive::Usage 4.103 + MooX::Options::Role 4.103 + requirements: + ExtUtils::MakeMaker 0 + Getopt::Long 2.43 + Getopt::Long::Descriptive 0.099 + MRO::Compat 0 + Module::Runtime 0 + Moo 1.003 + MooX::Locale::Passthrough 0 + Path::Class 0.32 + Pod::Usage 0 + Text::LineFold 0 + perl 5.008001 + strictures 2 + MooX-StrictConstructor-0.013 + pathname: H/HA/HAARG/MooX-StrictConstructor-0.013.tar.gz + provides: + MooX::StrictConstructor 0.013 + MooX::StrictConstructor::Role::BuildAll 0.013 + MooX::StrictConstructor::Role::Constructor 0.013 + MooX::StrictConstructor::Role::Constructor::Base 0.013 + MooX::StrictConstructor::Role::Constructor::Late 0.013 + requirements: + ExtUtils::MakeMaker 0 + Moo 2.004000 + Moo::Role 0 + perl 5.008 + MooX-Traits-0.005 + pathname: T/TO/TOBYINK/MooX-Traits-0.005.tar.gz + provides: + MooX::Traits 0.005 + MooX::Traits::Util 0.005 + requirements: + Exporter::Shiny 0 + ExtUtils::MakeMaker 6.17 + Module::Runtime 0 + Role::Tiny 1.000000 + Scalar::Util 0 + perl 5.006000 + MooX-Types-MooseLike-0.29 + pathname: M/MA/MATEU/MooX-Types-MooseLike-0.29.tar.gz + provides: + MooX::Types::MooseLike 0.29 + MooX::Types::MooseLike::Base 0.29 + requirements: + ExtUtils::MakeMaker 0 + Module::Runtime 0.014 + MooX-Types-MooseLike-Numeric-1.03 + pathname: M/MA/MATEU/MooX-Types-MooseLike-Numeric-1.03.tar.gz + provides: + MooX::Types::MooseLike::Numeric 1.03 + requirements: + ExtUtils::MakeMaker 0 + Moo 1.004002 + MooX::Types::MooseLike 0.23 + Test::Fatal 0.003 + Test::More 0.96 + Moose-2.2207 + pathname: E/ET/ETHER/Moose-2.2207.tar.gz + provides: + Class::MOP 2.2207 + Class::MOP::Attribute 2.2207 + Class::MOP::Class 2.2207 + Class::MOP::Class::Immutable::Trait 2.2207 + Class::MOP::Deprecated 2.2207 + Class::MOP::Instance 2.2207 + Class::MOP::Method 2.2207 + Class::MOP::Method::Accessor 2.2207 + Class::MOP::Method::Constructor 2.2207 + Class::MOP::Method::Generated 2.2207 + Class::MOP::Method::Inlined 2.2207 + Class::MOP::Method::Meta 2.2207 + Class::MOP::Method::Wrapped 2.2207 + Class::MOP::MiniTrait 2.2207 + Class::MOP::Mixin 2.2207 + Class::MOP::Mixin::AttributeCore 2.2207 + Class::MOP::Mixin::HasAttributes 2.2207 + Class::MOP::Mixin::HasMethods 2.2207 + Class::MOP::Mixin::HasOverloads 2.2207 + Class::MOP::Module 2.2207 + Class::MOP::Object 2.2207 + Class::MOP::Overload 2.2207 + Class::MOP::Package 2.2207 + Moose 2.2207 + Moose::Cookbook 2.2207 + Moose::Cookbook::Basics::BankAccount_MethodModifiersAndSubclassing 2.2207 + Moose::Cookbook::Basics::BinaryTree_AttributeFeatures 2.2207 + Moose::Cookbook::Basics::BinaryTree_BuilderAndLazyBuild 2.2207 + Moose::Cookbook::Basics::Company_Subtypes 2.2207 + Moose::Cookbook::Basics::DateTime_ExtendingNonMooseParent 2.2207 + Moose::Cookbook::Basics::Document_AugmentAndInner 2.2207 + Moose::Cookbook::Basics::Genome_OverloadingSubtypesAndCoercion 2.2207 + Moose::Cookbook::Basics::HTTP_SubtypesAndCoercion 2.2207 + Moose::Cookbook::Basics::Immutable 2.2207 + Moose::Cookbook::Basics::Person_BUILDARGSAndBUILD 2.2207 + Moose::Cookbook::Basics::Point_AttributesAndSubclassing 2.2207 + Moose::Cookbook::Extending::Debugging_BaseClassRole 2.2207 + Moose::Cookbook::Extending::ExtensionOverview 2.2207 + Moose::Cookbook::Extending::Mooseish_MooseSugar 2.2207 + Moose::Cookbook::Legacy::Debugging_BaseClassReplacement 2.2207 + Moose::Cookbook::Legacy::Labeled_AttributeMetaclass 2.2207 + Moose::Cookbook::Legacy::Table_ClassMetaclass 2.2207 + Moose::Cookbook::Meta::GlobRef_InstanceMetaclass 2.2207 + Moose::Cookbook::Meta::Labeled_AttributeTrait 2.2207 + Moose::Cookbook::Meta::PrivateOrPublic_MethodMetaclass 2.2207 + Moose::Cookbook::Meta::Table_MetaclassTrait 2.2207 + Moose::Cookbook::Meta::WhyMeta 2.2207 + Moose::Cookbook::Roles::ApplicationToInstance 2.2207 + Moose::Cookbook::Roles::Comparable_CodeReuse 2.2207 + Moose::Cookbook::Roles::Restartable_AdvancedComposition 2.2207 + Moose::Cookbook::Snack::Keywords 2.2207 + Moose::Cookbook::Snack::Types 2.2207 + Moose::Cookbook::Style 2.2207 + Moose::Deprecated 2.2207 + Moose::Exception 2.2207 + Moose::Exception::AccessorMustReadWrite 2.2207 + Moose::Exception::AddParameterizableTypeTakesParameterizableType 2.2207 + Moose::Exception::AddRoleTakesAMooseMetaRoleInstance 2.2207 + Moose::Exception::AddRoleToARoleTakesAMooseMetaRole 2.2207 + Moose::Exception::ApplyTakesABlessedInstance 2.2207 + Moose::Exception::AttachToClassNeedsAClassMOPClassInstanceOrASubclass 2.2207 + Moose::Exception::AttributeConflictInRoles 2.2207 + Moose::Exception::AttributeConflictInSummation 2.2207 + Moose::Exception::AttributeExtensionIsNotSupportedInRoles 2.2207 + Moose::Exception::AttributeIsRequired 2.2207 + Moose::Exception::AttributeMustBeAnClassMOPMixinAttributeCoreOrSubclass 2.2207 + Moose::Exception::AttributeNamesDoNotMatch 2.2207 + Moose::Exception::AttributeValueIsNotAnObject 2.2207 + Moose::Exception::AttributeValueIsNotDefined 2.2207 + Moose::Exception::AutoDeRefNeedsArrayRefOrHashRef 2.2207 + Moose::Exception::BadOptionFormat 2.2207 + Moose::Exception::BothBuilderAndDefaultAreNotAllowed 2.2207 + Moose::Exception::BuilderDoesNotExist 2.2207 + Moose::Exception::BuilderMethodNotSupportedForAttribute 2.2207 + Moose::Exception::BuilderMethodNotSupportedForInlineAttribute 2.2207 + Moose::Exception::BuilderMustBeAMethodName 2.2207 + Moose::Exception::CallingMethodOnAnImmutableInstance 2.2207 + Moose::Exception::CallingReadOnlyMethodOnAnImmutableInstance 2.2207 + Moose::Exception::CanExtendOnlyClasses 2.2207 + Moose::Exception::CanOnlyConsumeRole 2.2207 + Moose::Exception::CanOnlyWrapBlessedCode 2.2207 + Moose::Exception::CanReblessOnlyIntoASubclass 2.2207 + Moose::Exception::CanReblessOnlyIntoASuperclass 2.2207 + Moose::Exception::CannotAddAdditionalTypeCoercionsToUnion 2.2207 + Moose::Exception::CannotAddAsAnAttributeToARole 2.2207 + Moose::Exception::CannotApplyBaseClassRolesToRole 2.2207 + Moose::Exception::CannotAssignValueToReadOnlyAccessor 2.2207 + Moose::Exception::CannotAugmentIfLocalMethodPresent 2.2207 + Moose::Exception::CannotAugmentNoSuperMethod 2.2207 + Moose::Exception::CannotAutoDerefWithoutIsa 2.2207 + Moose::Exception::CannotAutoDereferenceTypeConstraint 2.2207 + Moose::Exception::CannotCalculateNativeType 2.2207 + Moose::Exception::CannotCallAnAbstractBaseMethod 2.2207 + Moose::Exception::CannotCallAnAbstractMethod 2.2207 + Moose::Exception::CannotCoerceAWeakRef 2.2207 + Moose::Exception::CannotCoerceAttributeWhichHasNoCoercion 2.2207 + Moose::Exception::CannotCreateHigherOrderTypeWithoutATypeParameter 2.2207 + Moose::Exception::CannotCreateMethodAliasLocalMethodIsPresent 2.2207 + Moose::Exception::CannotCreateMethodAliasLocalMethodIsPresentInClass 2.2207 + Moose::Exception::CannotDelegateLocalMethodIsPresent 2.2207 + Moose::Exception::CannotDelegateWithoutIsa 2.2207 + Moose::Exception::CannotFindDelegateMetaclass 2.2207 + Moose::Exception::CannotFindType 2.2207 + Moose::Exception::CannotFindTypeGivenToMatchOnType 2.2207 + Moose::Exception::CannotFixMetaclassCompatibility 2.2207 + Moose::Exception::CannotGenerateInlineConstraint 2.2207 + Moose::Exception::CannotInitializeMooseMetaRoleComposite 2.2207 + Moose::Exception::CannotInlineTypeConstraintCheck 2.2207 + Moose::Exception::CannotLocatePackageInINC 2.2207 + Moose::Exception::CannotMakeMetaclassCompatible 2.2207 + Moose::Exception::CannotOverrideALocalMethod 2.2207 + Moose::Exception::CannotOverrideBodyOfMetaMethods 2.2207 + Moose::Exception::CannotOverrideLocalMethodIsPresent 2.2207 + Moose::Exception::CannotOverrideNoSuperMethod 2.2207 + Moose::Exception::CannotRegisterUnnamedTypeConstraint 2.2207 + Moose::Exception::CannotUseLazyBuildAndDefaultSimultaneously 2.2207 + Moose::Exception::CircularReferenceInAlso 2.2207 + Moose::Exception::ClassDoesNotHaveInitMeta 2.2207 + Moose::Exception::ClassDoesTheExcludedRole 2.2207 + Moose::Exception::ClassNamesDoNotMatch 2.2207 + Moose::Exception::CloneObjectExpectsAnInstanceOfMetaclass 2.2207 + Moose::Exception::CodeBlockMustBeACodeRef 2.2207 + Moose::Exception::CoercingWithoutCoercions 2.2207 + Moose::Exception::CoercionAlreadyExists 2.2207 + Moose::Exception::CoercionNeedsTypeConstraint 2.2207 + Moose::Exception::ConflictDetectedInCheckRoleExclusions 2.2207 + Moose::Exception::ConflictDetectedInCheckRoleExclusionsInToClass 2.2207 + Moose::Exception::ConstructClassInstanceTakesPackageName 2.2207 + Moose::Exception::CouldNotCreateMethod 2.2207 + Moose::Exception::CouldNotCreateWriter 2.2207 + Moose::Exception::CouldNotEvalConstructor 2.2207 + Moose::Exception::CouldNotEvalDestructor 2.2207 + Moose::Exception::CouldNotFindTypeConstraintToCoerceFrom 2.2207 + Moose::Exception::CouldNotGenerateInlineAttributeMethod 2.2207 + Moose::Exception::CouldNotLocateTypeConstraintForUnion 2.2207 + Moose::Exception::CouldNotParseType 2.2207 + Moose::Exception::CreateMOPClassTakesArrayRefOfAttributes 2.2207 + Moose::Exception::CreateMOPClassTakesArrayRefOfSuperclasses 2.2207 + Moose::Exception::CreateMOPClassTakesHashRefOfMethods 2.2207 + Moose::Exception::CreateTakesArrayRefOfRoles 2.2207 + Moose::Exception::CreateTakesHashRefOfAttributes 2.2207 + Moose::Exception::CreateTakesHashRefOfMethods 2.2207 + Moose::Exception::DefaultToMatchOnTypeMustBeCodeRef 2.2207 + Moose::Exception::DelegationToAClassWhichIsNotLoaded 2.2207 + Moose::Exception::DelegationToARoleWhichIsNotLoaded 2.2207 + Moose::Exception::DelegationToATypeWhichIsNotAClass 2.2207 + Moose::Exception::DoesRequiresRoleName 2.2207 + Moose::Exception::EnumCalledWithAnArrayRefAndAdditionalArgs 2.2207 + Moose::Exception::EnumValuesMustBeString 2.2207 + Moose::Exception::ExtendsMissingArgs 2.2207 + Moose::Exception::HandlesMustBeAHashRef 2.2207 + Moose::Exception::IllegalInheritedOptions 2.2207 + Moose::Exception::IllegalMethodTypeToAddMethodModifier 2.2207 + Moose::Exception::IncompatibleMetaclassOfSuperclass 2.2207 + Moose::Exception::InitMetaRequiresClass 2.2207 + Moose::Exception::InitializeTakesUnBlessedPackageName 2.2207 + Moose::Exception::InstanceBlessedIntoWrongClass 2.2207 + Moose::Exception::InstanceMustBeABlessedReference 2.2207 + Moose::Exception::InvalidArgPassedToMooseUtilMetaRole 2.2207 + Moose::Exception::InvalidArgumentToMethod 2.2207 + Moose::Exception::InvalidArgumentsToTraitAliases 2.2207 + Moose::Exception::InvalidBaseTypeGivenToCreateParameterizedTypeConstraint 2.2207 + Moose::Exception::InvalidHandleValue 2.2207 + Moose::Exception::InvalidHasProvidedInARole 2.2207 + Moose::Exception::InvalidNameForType 2.2207 + Moose::Exception::InvalidOverloadOperator 2.2207 + Moose::Exception::InvalidRoleApplication 2.2207 + Moose::Exception::InvalidTypeConstraint 2.2207 + Moose::Exception::InvalidTypeGivenToCreateParameterizedTypeConstraint 2.2207 + Moose::Exception::InvalidValueForIs 2.2207 + Moose::Exception::IsaDoesNotDoTheRole 2.2207 + Moose::Exception::IsaLacksDoesMethod 2.2207 + Moose::Exception::LazyAttributeNeedsADefault 2.2207 + Moose::Exception::Legacy 2.2207 + Moose::Exception::MOPAttributeNewNeedsAttributeName 2.2207 + Moose::Exception::MatchActionMustBeACodeRef 2.2207 + Moose::Exception::MessageParameterMustBeCodeRef 2.2207 + Moose::Exception::MetaclassIsAClassNotASubclassOfGivenMetaclass 2.2207 + Moose::Exception::MetaclassIsARoleNotASubclassOfGivenMetaclass 2.2207 + Moose::Exception::MetaclassIsNotASubclassOfGivenMetaclass 2.2207 + Moose::Exception::MetaclassMustBeASubclassOfMooseMetaClass 2.2207 + Moose::Exception::MetaclassMustBeASubclassOfMooseMetaRole 2.2207 + Moose::Exception::MetaclassMustBeDerivedFromClassMOPClass 2.2207 + Moose::Exception::MetaclassNotLoaded 2.2207 + Moose::Exception::MetaclassTypeIncompatible 2.2207 + Moose::Exception::MethodExpectedAMetaclassObject 2.2207 + Moose::Exception::MethodExpectsFewerArgs 2.2207 + Moose::Exception::MethodExpectsMoreArgs 2.2207 + Moose::Exception::MethodModifierNeedsMethodName 2.2207 + Moose::Exception::MethodNameConflictInRoles 2.2207 + Moose::Exception::MethodNameNotFoundInInheritanceHierarchy 2.2207 + Moose::Exception::MethodNameNotGiven 2.2207 + Moose::Exception::MustDefineAMethodName 2.2207 + Moose::Exception::MustDefineAnAttributeName 2.2207 + Moose::Exception::MustDefineAnOverloadOperator 2.2207 + Moose::Exception::MustHaveAtLeastOneValueToEnumerate 2.2207 + Moose::Exception::MustPassAHashOfOptions 2.2207 + Moose::Exception::MustPassAMooseMetaRoleInstanceOrSubclass 2.2207 + Moose::Exception::MustPassAPackageNameOrAnExistingClassMOPPackageInstance 2.2207 + Moose::Exception::MustPassEvenNumberOfArguments 2.2207 + Moose::Exception::MustPassEvenNumberOfAttributeOptions 2.2207 + Moose::Exception::MustProvideANameForTheAttribute 2.2207 + Moose::Exception::MustSpecifyAtleastOneMethod 2.2207 + Moose::Exception::MustSpecifyAtleastOneRole 2.2207 + Moose::Exception::MustSpecifyAtleastOneRoleToApplicant 2.2207 + Moose::Exception::MustSupplyAClassMOPAttributeInstance 2.2207 + Moose::Exception::MustSupplyADelegateToMethod 2.2207 + Moose::Exception::MustSupplyAMetaclass 2.2207 + Moose::Exception::MustSupplyAMooseMetaAttributeInstance 2.2207 + Moose::Exception::MustSupplyAnAccessorTypeToConstructWith 2.2207 + Moose::Exception::MustSupplyAnAttributeToConstructWith 2.2207 + Moose::Exception::MustSupplyArrayRefAsCurriedArguments 2.2207 + Moose::Exception::MustSupplyPackageNameAndName 2.2207 + Moose::Exception::NeedsTypeConstraintUnionForTypeCoercionUnion 2.2207 + Moose::Exception::NeitherAttributeNorAttributeNameIsGiven 2.2207 + Moose::Exception::NeitherClassNorClassNameIsGiven 2.2207 + Moose::Exception::NeitherRoleNorRoleNameIsGiven 2.2207 + Moose::Exception::NeitherTypeNorTypeNameIsGiven 2.2207 + Moose::Exception::NoAttributeFoundInSuperClass 2.2207 + Moose::Exception::NoBodyToInitializeInAnAbstractBaseClass 2.2207 + Moose::Exception::NoCasesMatched 2.2207 + Moose::Exception::NoConstraintCheckForTypeConstraint 2.2207 + Moose::Exception::NoDestructorClassSpecified 2.2207 + Moose::Exception::NoImmutableTraitSpecifiedForClass 2.2207 + Moose::Exception::NoParentGivenToSubtype 2.2207 + Moose::Exception::OnlyInstancesCanBeCloned 2.2207 + Moose::Exception::OperatorIsRequired 2.2207 + Moose::Exception::OverloadConflictInSummation 2.2207 + Moose::Exception::OverloadRequiresAMetaClass 2.2207 + Moose::Exception::OverloadRequiresAMetaMethod 2.2207 + Moose::Exception::OverloadRequiresAMetaOverload 2.2207 + Moose::Exception::OverloadRequiresAMethodNameOrCoderef 2.2207 + Moose::Exception::OverloadRequiresAnOperator 2.2207 + Moose::Exception::OverloadRequiresNamesForCoderef 2.2207 + Moose::Exception::OverrideConflictInComposition 2.2207 + Moose::Exception::OverrideConflictInSummation 2.2207 + Moose::Exception::PackageDoesNotUseMooseExporter 2.2207 + Moose::Exception::PackageNameAndNameParamsNotGivenToWrap 2.2207 + Moose::Exception::PackagesAndModulesAreNotCachable 2.2207 + Moose::Exception::ParameterIsNotSubtypeOfParent 2.2207 + Moose::Exception::ReferencesAreNotAllowedAsDefault 2.2207 + Moose::Exception::RequiredAttributeLacksInitialization 2.2207 + Moose::Exception::RequiredAttributeNeedsADefault 2.2207 + Moose::Exception::RequiredMethodsImportedByClass 2.2207 + Moose::Exception::RequiredMethodsNotImplementedByClass 2.2207 + Moose::Exception::Role::Attribute 2.2207 + Moose::Exception::Role::AttributeName 2.2207 + Moose::Exception::Role::Class 2.2207 + Moose::Exception::Role::EitherAttributeOrAttributeName 2.2207 + Moose::Exception::Role::Instance 2.2207 + Moose::Exception::Role::InstanceClass 2.2207 + Moose::Exception::Role::InvalidAttributeOptions 2.2207 + Moose::Exception::Role::Method 2.2207 + Moose::Exception::Role::ParamsHash 2.2207 + Moose::Exception::Role::Role 2.2207 + Moose::Exception::Role::RoleForCreate 2.2207 + Moose::Exception::Role::RoleForCreateMOPClass 2.2207 + Moose::Exception::Role::TypeConstraint 2.2207 + Moose::Exception::RoleDoesTheExcludedRole 2.2207 + Moose::Exception::RoleExclusionConflict 2.2207 + Moose::Exception::RoleNameRequired 2.2207 + Moose::Exception::RoleNameRequiredForMooseMetaRole 2.2207 + Moose::Exception::RolesDoNotSupportAugment 2.2207 + Moose::Exception::RolesDoNotSupportExtends 2.2207 + Moose::Exception::RolesDoNotSupportInner 2.2207 + Moose::Exception::RolesDoNotSupportRegexReferencesForMethodModifiers 2.2207 + Moose::Exception::RolesInCreateTakesAnArrayRef 2.2207 + Moose::Exception::RolesListMustBeInstancesOfMooseMetaRole 2.2207 + Moose::Exception::SingleParamsToNewMustBeHashRef 2.2207 + Moose::Exception::TriggerMustBeACodeRef 2.2207 + Moose::Exception::TypeConstraintCannotBeUsedForAParameterizableType 2.2207 + Moose::Exception::TypeConstraintIsAlreadyCreated 2.2207 + Moose::Exception::TypeParameterMustBeMooseMetaType 2.2207 + Moose::Exception::UnableToCanonicalizeHandles 2.2207 + Moose::Exception::UnableToCanonicalizeNonRolePackage 2.2207 + Moose::Exception::UnableToRecognizeDelegateMetaclass 2.2207 + Moose::Exception::UndefinedHashKeysPassedToMethod 2.2207 + Moose::Exception::UnionCalledWithAnArrayRefAndAdditionalArgs 2.2207 + Moose::Exception::UnionTakesAtleastTwoTypeNames 2.2207 + Moose::Exception::ValidationFailedForInlineTypeConstraint 2.2207 + Moose::Exception::ValidationFailedForTypeConstraint 2.2207 + Moose::Exception::WrapTakesACodeRefToBless 2.2207 + Moose::Exception::WrongTypeConstraintGiven 2.2207 + Moose::Exporter 2.2207 + Moose::Intro 2.2207 + Moose::Manual 2.2207 + Moose::Manual::Attributes 2.2207 + Moose::Manual::BestPractices 2.2207 + Moose::Manual::Classes 2.2207 + Moose::Manual::Concepts 2.2207 + Moose::Manual::Construction 2.2207 + Moose::Manual::Contributing 2.2207 + Moose::Manual::Delegation 2.2207 + Moose::Manual::Delta 2.2207 + Moose::Manual::Exceptions 2.2207 + Moose::Manual::Exceptions::Manifest 2.2207 + Moose::Manual::FAQ 2.2207 + Moose::Manual::MOP 2.2207 + Moose::Manual::MethodModifiers 2.2207 + Moose::Manual::MooseX 2.2207 + Moose::Manual::Resources 2.2207 + Moose::Manual::Roles 2.2207 + Moose::Manual::Support 2.2207 + Moose::Manual::Types 2.2207 + Moose::Manual::Unsweetened 2.2207 + Moose::Meta::Attribute 2.2207 + Moose::Meta::Attribute::Native 2.2207 + Moose::Meta::Attribute::Native::Trait 2.2207 + Moose::Meta::Attribute::Native::Trait::Array 2.2207 + Moose::Meta::Attribute::Native::Trait::Bool 2.2207 + Moose::Meta::Attribute::Native::Trait::Code 2.2207 + Moose::Meta::Attribute::Native::Trait::Counter 2.2207 + Moose::Meta::Attribute::Native::Trait::Hash 2.2207 + Moose::Meta::Attribute::Native::Trait::Number 2.2207 + Moose::Meta::Attribute::Native::Trait::String 2.2207 + Moose::Meta::Class 2.2207 + Moose::Meta::Class::Immutable::Trait 2.2207 + Moose::Meta::Instance 2.2207 + Moose::Meta::Method 2.2207 + Moose::Meta::Method::Accessor 2.2207 + Moose::Meta::Method::Accessor::Native 2.2207 + Moose::Meta::Method::Accessor::Native::Array 2.2207 + Moose::Meta::Method::Accessor::Native::Array::Writer 2.2207 + Moose::Meta::Method::Accessor::Native::Array::accessor 2.2207 + Moose::Meta::Method::Accessor::Native::Array::clear 2.2207 + Moose::Meta::Method::Accessor::Native::Array::count 2.2207 + Moose::Meta::Method::Accessor::Native::Array::delete 2.2207 + Moose::Meta::Method::Accessor::Native::Array::elements 2.2207 + Moose::Meta::Method::Accessor::Native::Array::first 2.2207 + Moose::Meta::Method::Accessor::Native::Array::first_index 2.2207 + Moose::Meta::Method::Accessor::Native::Array::get 2.2207 + Moose::Meta::Method::Accessor::Native::Array::grep 2.2207 + Moose::Meta::Method::Accessor::Native::Array::insert 2.2207 + Moose::Meta::Method::Accessor::Native::Array::is_empty 2.2207 + Moose::Meta::Method::Accessor::Native::Array::join 2.2207 + Moose::Meta::Method::Accessor::Native::Array::map 2.2207 + Moose::Meta::Method::Accessor::Native::Array::natatime 2.2207 + Moose::Meta::Method::Accessor::Native::Array::pop 2.2207 + Moose::Meta::Method::Accessor::Native::Array::push 2.2207 + Moose::Meta::Method::Accessor::Native::Array::reduce 2.2207 + Moose::Meta::Method::Accessor::Native::Array::set 2.2207 + Moose::Meta::Method::Accessor::Native::Array::shallow_clone 2.2207 + Moose::Meta::Method::Accessor::Native::Array::shift 2.2207 + Moose::Meta::Method::Accessor::Native::Array::shuffle 2.2207 + Moose::Meta::Method::Accessor::Native::Array::sort 2.2207 + Moose::Meta::Method::Accessor::Native::Array::sort_in_place 2.2207 + Moose::Meta::Method::Accessor::Native::Array::splice 2.2207 + Moose::Meta::Method::Accessor::Native::Array::uniq 2.2207 + Moose::Meta::Method::Accessor::Native::Array::unshift 2.2207 + Moose::Meta::Method::Accessor::Native::Bool::not 2.2207 + Moose::Meta::Method::Accessor::Native::Bool::set 2.2207 + Moose::Meta::Method::Accessor::Native::Bool::toggle 2.2207 + Moose::Meta::Method::Accessor::Native::Bool::unset 2.2207 + Moose::Meta::Method::Accessor::Native::Code::execute 2.2207 + Moose::Meta::Method::Accessor::Native::Code::execute_method 2.2207 + Moose::Meta::Method::Accessor::Native::Collection 2.2207 + Moose::Meta::Method::Accessor::Native::Counter::Writer 2.2207 + Moose::Meta::Method::Accessor::Native::Counter::dec 2.2207 + Moose::Meta::Method::Accessor::Native::Counter::inc 2.2207 + Moose::Meta::Method::Accessor::Native::Counter::reset 2.2207 + Moose::Meta::Method::Accessor::Native::Counter::set 2.2207 + Moose::Meta::Method::Accessor::Native::Hash 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::Writer 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::accessor 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::clear 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::count 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::defined 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::delete 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::elements 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::exists 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::get 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::is_empty 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::keys 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::kv 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::set 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::shallow_clone 2.2207 + Moose::Meta::Method::Accessor::Native::Hash::values 2.2207 + Moose::Meta::Method::Accessor::Native::Number::abs 2.2207 + Moose::Meta::Method::Accessor::Native::Number::add 2.2207 + Moose::Meta::Method::Accessor::Native::Number::div 2.2207 + Moose::Meta::Method::Accessor::Native::Number::mod 2.2207 + Moose::Meta::Method::Accessor::Native::Number::mul 2.2207 + Moose::Meta::Method::Accessor::Native::Number::set 2.2207 + Moose::Meta::Method::Accessor::Native::Number::sub 2.2207 + Moose::Meta::Method::Accessor::Native::Reader 2.2207 + Moose::Meta::Method::Accessor::Native::String::append 2.2207 + Moose::Meta::Method::Accessor::Native::String::chomp 2.2207 + Moose::Meta::Method::Accessor::Native::String::chop 2.2207 + Moose::Meta::Method::Accessor::Native::String::clear 2.2207 + Moose::Meta::Method::Accessor::Native::String::inc 2.2207 + Moose::Meta::Method::Accessor::Native::String::length 2.2207 + Moose::Meta::Method::Accessor::Native::String::match 2.2207 + Moose::Meta::Method::Accessor::Native::String::prepend 2.2207 + Moose::Meta::Method::Accessor::Native::String::replace 2.2207 + Moose::Meta::Method::Accessor::Native::String::substr 2.2207 + Moose::Meta::Method::Accessor::Native::Writer 2.2207 + Moose::Meta::Method::Augmented 2.2207 + Moose::Meta::Method::Constructor 2.2207 + Moose::Meta::Method::Delegation 2.2207 + Moose::Meta::Method::Destructor 2.2207 + Moose::Meta::Method::Meta 2.2207 + Moose::Meta::Method::Overridden 2.2207 + Moose::Meta::Mixin::AttributeCore 2.2207 + Moose::Meta::Object::Trait 2.2207 + Moose::Meta::Role 2.2207 + Moose::Meta::Role::Application 2.2207 + Moose::Meta::Role::Application::RoleSummation 2.2207 + Moose::Meta::Role::Application::ToClass 2.2207 + Moose::Meta::Role::Application::ToInstance 2.2207 + Moose::Meta::Role::Application::ToRole 2.2207 + Moose::Meta::Role::Attribute 2.2207 + Moose::Meta::Role::Composite 2.2207 + Moose::Meta::Role::Method 2.2207 + Moose::Meta::Role::Method::Conflicting 2.2207 + Moose::Meta::Role::Method::Required 2.2207 + Moose::Meta::TypeCoercion 2.2207 + Moose::Meta::TypeCoercion::Union 2.2207 + Moose::Meta::TypeConstraint 2.2207 + Moose::Meta::TypeConstraint::Class 2.2207 + Moose::Meta::TypeConstraint::DuckType 2.2207 + Moose::Meta::TypeConstraint::Enum 2.2207 + Moose::Meta::TypeConstraint::Parameterizable 2.2207 + Moose::Meta::TypeConstraint::Parameterized 2.2207 + Moose::Meta::TypeConstraint::Registry 2.2207 + Moose::Meta::TypeConstraint::Role 2.2207 + Moose::Meta::TypeConstraint::Union 2.2207 + Moose::Object 2.2207 + Moose::Role 2.2207 + Moose::Spec::Role 2.2207 + Moose::Unsweetened 2.2207 + Moose::Util 2.2207 + Moose::Util::MetaRole 2.2207 + Moose::Util::TypeConstraints 2.2207 + Moose::Util::TypeConstraints::Builtins 2.2207 + Test::Moose 2.2207 + metaclass 2.2207 + oose 2.2207 + requirements: + Carp 1.22 + Class::Load 0.09 + Class::Load::XS 0.01 + Data::OptList 0.107 + Devel::GlobalDestruction 0 + Devel::OverloadInfo 0.005 + Devel::StackTrace 2.03 + Dist::CheckConflicts 0.02 + Eval::Closure 0.04 + ExtUtils::MakeMaker 0 + List::Util 1.56 + MRO::Compat 0.05 + Module::Runtime 0.014 + Module::Runtime::Conflicts 0.002 + Package::DeprecationManager 0.11 + Package::Stash 0.32 + Package::Stash::XS 0.24 + Params::Util 1.00 + Scalar::Util 1.19 + Sub::Exporter 0.980 + Sub::Util 1.40 + Try::Tiny 0.17 + parent 0.223 + strict 1.03 + warnings 1.03 + MooseX-Attribute-Chained-1.0.3 + pathname: T/TO/TOMHUKINS/MooseX-Attribute-Chained-1.0.3.tar.gz + provides: + Moose::Meta::Attribute::Custom::Trait::Chained v1.0.3 + MooseX::Attribute::Chained v1.0.3 + MooseX::Attribute::Chained::Method::Accessor v1.0.3 + MooseX::Attribute::ChainedClone v1.0.3 + MooseX::Attribute::ChainedClone::Method::Accessor v1.0.3 + MooseX::ChainedAccessors v1.0.3 + MooseX::ChainedAccessors::Accessor v1.0.3 + MooseX::Traits::Attribute::Chained v1.0.3 + MooseX::Traits::Attribute::ChainedClone v1.0.3 + requirements: + Module::Build 0.28 + Moose 0 + Test::More 0.88 + Try::Tiny 0 + MooseX-Attribute-Deflator-2.2.2 + pathname: P/PE/PERLER/MooseX-Attribute-Deflator-2.2.2.tar.gz + provides: + MooseX::Attribute::Deflator v2.2.2 + MooseX::Attribute::Deflator::Meta::Role::Attribute v2.2.2 + MooseX::Attribute::Deflator::Moose v2.2.2 + MooseX::Attribute::Deflator::Registry v2.2.2 + MooseX::Attribute::Deflator::Structured v2.2.2 + MooseX::Attribute::LazyInflator v2.2.2 + MooseX::Attribute::LazyInflator::Meta::Role::ApplicationToClass v2.2.2 + MooseX::Attribute::LazyInflator::Meta::Role::ApplicationToRole v2.2.2 + MooseX::Attribute::LazyInflator::Meta::Role::Attribute v2.2.2 + MooseX::Attribute::LazyInflator::Meta::Role::Composite v2.2.2 + MooseX::Attribute::LazyInflator::Meta::Role::Method::Accessor v2.2.2 + MooseX::Attribute::LazyInflator::Meta::Role::Method::Constructor v2.2.2 + MooseX::Attribute::LazyInflator::Meta::Role::Role v2.2.2 + MooseX::Attribute::LazyInflator::Role::Class v2.2.2 + requirements: + DateTime 0 + Devel::PartialDump 0 + File::Find 0 + File::Temp 0 + JSON 0 + Module::Build 0.3601 + Moose 1.25 + Moose::Util::TypeConstraints 0 + MooseX::Types 0.30 + MooseX::Types::Moose 0 + MooseX::Types::Structured 0 + Test::More 0.88 + Try::Tiny 0 + MooseX-Emulate-Class-Accessor-Fast-0.009032 + pathname: H/HA/HAARG/MooseX-Emulate-Class-Accessor-Fast-0.009032.tar.gz + provides: + MooseX::Adopt::Class::Accessor::Fast 0.009032 + MooseX::Emulate::Class::Accessor::Fast 0.009032 + MooseX::Emulate::Class::Accessor::Fast::Meta::Accessor undef + MooseX::Emulate::Class::Accessor::Fast::Meta::Role::Attribute undef + requirements: + ExtUtils::MakeMaker 0 + Moose 0.84 + namespace::clean 0 + MooseX-Fastly-Role-0.04 + pathname: L/LL/LLAP/MooseX-Fastly-Role-0.04.tar.gz + provides: + MooseX::Fastly::Role 0.04 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + HTTP::Tiny 0 + Moose::Role 0 + Net::Fastly 1.08 + MooseX-Getopt-0.78 + pathname: E/ET/ETHER/MooseX-Getopt-0.78.tar.gz + provides: + MooseX::Getopt 0.78 + MooseX::Getopt::Basic 0.78 + MooseX::Getopt::Dashes 0.78 + MooseX::Getopt::GLD 0.78 + MooseX::Getopt::Meta::Attribute 0.78 + MooseX::Getopt::Meta::Attribute::NoGetopt 0.78 + MooseX::Getopt::Meta::Attribute::Trait 0.78 + MooseX::Getopt::Meta::Attribute::Trait::NoGetopt 0.78 + MooseX::Getopt::OptionTypeMap 0.78 + MooseX::Getopt::ProcessedArgv 0.78 + MooseX::Getopt::Strict 0.78 + requirements: + Carp 0 + Getopt::Long 2.37 + Getopt::Long::Descriptive 0.088 + Module::Build::Tiny 0.034 + Moose 0 + Moose::Meta::Attribute 0 + Moose::Role 0.56 + Moose::Util::TypeConstraints 0 + MooseX::Role::Parameterized 1.01 + Scalar::Util 0 + Try::Tiny 0 + namespace::autoclean 0 + perl 5.006 + strict 0 + warnings 0 + MooseX-MethodAttributes-0.32 + pathname: E/ET/ETHER/MooseX-MethodAttributes-0.32.tar.gz + provides: + MooseX::MethodAttributes 0.32 + MooseX::MethodAttributes::Inheritable 0.32 + MooseX::MethodAttributes::Role 0.32 + MooseX::MethodAttributes::Role::AttrContainer 0.32 + MooseX::MethodAttributes::Role::AttrContainer::Inheritable 0.32 + MooseX::MethodAttributes::Role::Meta::Class 0.32 + MooseX::MethodAttributes::Role::Meta::Map 0.32 + MooseX::MethodAttributes::Role::Meta::Method 0.32 + MooseX::MethodAttributes::Role::Meta::Method::MaybeWrapped 0.32 + MooseX::MethodAttributes::Role::Meta::Method::Wrapped 0.32 + MooseX::MethodAttributes::Role::Meta::Role 0.32 + MooseX::MethodAttributes::Role::Meta::Role::Application 0.32 + MooseX::MethodAttributes::Role::Meta::Role::Application::Summation 0.32 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + Moose 0 + Moose::Exporter 0 + Moose::Role 0 + Moose::Util 0 + Moose::Util::MetaRole 0 + namespace::autoclean 0.08 + perl 5.006 + MooseX-Role-Parameterized-1.11 + pathname: E/ET/ETHER/MooseX-Role-Parameterized-1.11.tar.gz + provides: + MooseX::Role::Parameterised 1.11 + MooseX::Role::Parameterized 1.11 + MooseX::Role::Parameterized::Meta::Role::Parameterized 1.11 + MooseX::Role::Parameterized::Meta::Trait::Parameterizable 1.11 + MooseX::Role::Parameterized::Meta::Trait::Parameterized 1.11 + MooseX::Role::Parameterized::Parameters 1.11 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + Module::Build::Tiny 0.034 + Module::Runtime 0 + Moose 2.0300 + Moose::Exporter 0 + Moose::Meta::Role 0 + Moose::Role 0 + Moose::Util 0 + namespace::autoclean 0 + namespace::clean 0.19 + perl 5.008001 + strict 0 + warnings 0 + MooseX-StrictConstructor-0.21 + pathname: D/DR/DROLSKY/MooseX-StrictConstructor-0.21.tar.gz + provides: + MooseX::StrictConstructor 0.21 + MooseX::StrictConstructor::Trait::Class 0.21 + MooseX::StrictConstructor::Trait::Method::Constructor 0.21 + requirements: + B 0 + ExtUtils::MakeMaker 0 + Moose 0.94 + Moose::Exporter 0 + Moose::Role 0 + Moose::Util::MetaRole 0 + namespace::autoclean 0 + strict 0 + warnings 0 + MooseX-Types-0.51 + pathname: E/ET/ETHER/MooseX-Types-0.51.tar.gz + provides: + MooseX::Types 0.51 + MooseX::Types::Base 0.51 + MooseX::Types::CheckedUtilExports 0.51 + MooseX::Types::Combine 0.51 + MooseX::Types::Moose 0.51 + MooseX::Types::TypeDecorator 0.51 + MooseX::Types::UndefinedType 0.51 + MooseX::Types::Util 0.51 + MooseX::Types::Wrapper 0.51 + requirements: + Carp 0 + Carp::Clan 6.00 + Exporter 0 + Module::Build::Tiny 0.034 + Module::Runtime 0 + Moose 1.06 + Moose::Exporter 0 + Moose::Meta::TypeConstraint::Union 0 + Moose::Util::TypeConstraints 0 + Scalar::Util 1.19 + Sub::Exporter 0 + Sub::Exporter::ForMethods 0.100052 + Sub::Install 0 + Sub::Util 0 + base 0 + namespace::autoclean 0.16 + overload 0 + parent 0 + perl 5.008 + strict 0 + warnings 0 + MooseX-Types-ElasticSearch-0.0.4 + pathname: P/PE/PERLER/MooseX-Types-ElasticSearch-0.0.4.tar.gz + provides: + MooseX::Types::ElasticSearch v0.0.4 + requirements: + DateTime::Format::Epoch::Unix 0 + DateTime::Format::ISO8601 0 + Module::Build 0.3601 + MooseX::Types 0 + Search::Elasticsearch 0 + MooseX-Types-Structured-0.36 + pathname: E/ET/ETHER/MooseX-Types-Structured-0.36.tar.gz + provides: + MooseX::Types::Structured 0.36 + requirements: + Devel::PartialDump 0.13 + Module::Build::Tiny 0.034 + Moose 0 + Moose::Meta::TypeCoercion 0 + Moose::Meta::TypeConstraint 0 + Moose::Meta::TypeConstraint::Parameterizable 0 + Moose::Util::TypeConstraints 1.06 + MooseX::Types 0.22 + Scalar::Util 0 + Sub::Exporter 0.982 + if 0 + namespace::clean 0.19 + overload 0 + perl 5.008 + Mozilla-CA-20250202 + pathname: L/LW/LWP/Mozilla-CA-20250202.tar.gz + provides: + Mozilla::CA 20250202 + requirements: + ExtUtils::MakeMaker 0 + Net-DNS-1.50 + pathname: N/NL/NLNETLABS/Net-DNS-1.50.tar.gz + provides: + Net::DNS 1.50 + Net::DNS::Domain 2002 + Net::DNS::DomainName 2005 + Net::DNS::DomainName1035 2005 + Net::DNS::DomainName2535 2005 + Net::DNS::Header 2002 + Net::DNS::Mailbox 2002 + Net::DNS::Mailbox1035 2002 + Net::DNS::Mailbox2535 2002 + Net::DNS::Nameserver 2002 + Net::DNS::Packet 2003 + Net::DNS::Parameters 2002 + Net::DNS::Question 2002 + Net::DNS::RR 2003 + Net::DNS::RR::A 2003 + Net::DNS::RR::AAAA 2003 + Net::DNS::RR::AFSDB 2002 + Net::DNS::RR::AMTRELAY 2003 + Net::DNS::RR::APL 2003 + Net::DNS::RR::APL::Item 2003 + Net::DNS::RR::CAA 2003 + Net::DNS::RR::CDNSKEY 2003 + Net::DNS::RR::CDS 2003 + Net::DNS::RR::CERT 2002 + Net::DNS::RR::CNAME 2003 + Net::DNS::RR::CSYNC 2003 + Net::DNS::RR::DELEG 2003 + Net::DNS::RR::DHCID 2003 + Net::DNS::RR::DNAME 2003 + Net::DNS::RR::DNSKEY 2003 + Net::DNS::RR::DS 2003 + Net::DNS::RR::DSYNC 2003 + Net::DNS::RR::EUI48 2003 + Net::DNS::RR::EUI64 2003 + Net::DNS::RR::GPOS 2003 + Net::DNS::RR::HINFO 2003 + Net::DNS::RR::HIP 2003 + Net::DNS::RR::HTTPS 2002 + Net::DNS::RR::IPSECKEY 2003 + Net::DNS::RR::ISDN 2002 + Net::DNS::RR::KEY 2002 + Net::DNS::RR::KX 2003 + Net::DNS::RR::L32 2003 + Net::DNS::RR::L64 2003 + Net::DNS::RR::LOC 2003 + Net::DNS::RR::LP 2003 + Net::DNS::RR::MB 2002 + Net::DNS::RR::MG 2002 + Net::DNS::RR::MINFO 2002 + Net::DNS::RR::MR 2002 + Net::DNS::RR::MX 2002 + Net::DNS::RR::NAPTR 2003 + Net::DNS::RR::NID 2003 + Net::DNS::RR::NS 2003 + Net::DNS::RR::NSEC 2002 + Net::DNS::RR::NSEC3 2003 + Net::DNS::RR::NSEC3PARAM 2003 + Net::DNS::RR::NULL 2002 + Net::DNS::RR::OPENPGPKEY 2003 + Net::DNS::RR::OPT 2005 + Net::DNS::RR::OPT::CHAIN 2005 + Net::DNS::RR::OPT::CLIENT_SUBNET 2005 + Net::DNS::RR::OPT::COOKIE 2005 + Net::DNS::RR::OPT::DAU 2005 + Net::DNS::RR::OPT::DHU 2005 + Net::DNS::RR::OPT::EXPIRE 2005 + Net::DNS::RR::OPT::EXTENDED_ERROR 2005 + Net::DNS::RR::OPT::KEY_TAG 2005 + Net::DNS::RR::OPT::N3U 2005 + Net::DNS::RR::OPT::NSID 2005 + Net::DNS::RR::OPT::PADDING 2005 + Net::DNS::RR::OPT::REPORT_CHANNEL 2005 + Net::DNS::RR::OPT::TCP_KEEPALIVE 2005 + Net::DNS::RR::OPT::ZONEVERSION 2005 + Net::DNS::RR::PTR 2002 + Net::DNS::RR::PX 2003 + Net::DNS::RR::RESINFO 2003 + Net::DNS::RR::RP 2002 + Net::DNS::RR::RRSIG 2003 + Net::DNS::RR::RT 2003 + Net::DNS::RR::SIG 2003 + Net::DNS::RR::SMIMEA 2003 + Net::DNS::RR::SOA 2002 + Net::DNS::RR::SPF 2003 + Net::DNS::RR::SRV 2003 + Net::DNS::RR::SSHFP 2003 + Net::DNS::RR::SVCB 2003 + Net::DNS::RR::TKEY 2003 + Net::DNS::RR::TLSA 2003 + Net::DNS::RR::TSIG 2003 + Net::DNS::RR::TXT 2003 + Net::DNS::RR::URI 2003 + Net::DNS::RR::X25 2002 + Net::DNS::RR::ZONEMD 2003 + Net::DNS::Resolver 2009 + Net::DNS::Resolver::Base 2011 + Net::DNS::Resolver::MSWin32 2002 + Net::DNS::Resolver::Recurse 2002 + Net::DNS::Resolver::UNIX 2007 + Net::DNS::Resolver::android 2007 + Net::DNS::Resolver::cygwin 2002 + Net::DNS::Resolver::os2 2007 + Net::DNS::Resolver::os390 2007 + Net::DNS::Text 2002 + Net::DNS::Update 2003 + Net::DNS::ZoneFile 2002 + Net::DNS::ZoneFile::Generator 2002 + Net::DNS::ZoneFile::Text 2002 + requirements: + Carp 1.1 + Config 0 + Digest::HMAC 1.03 + Digest::MD5 2.37 + Digest::SHA 5.23 + Encode 2.26 + Exporter 5.63 + ExtUtils::MakeMaker 6.48 + File::Spec 3.29 + Getopt::Long 2.43 + IO::File 1.14 + IO::Select 1.17 + IO::Socket 1.3 + IO::Socket::IP 0.38 + MIME::Base64 3.07 + PerlIO 1.05 + Scalar::Util 1.19 + Socket 1.81 + Time::Local 1.19 + base 2.13 + constant 1.17 + integer 1 + overload 1.06 + perl 5.008009 + strict 1.03 + warnings 1.0501 + Net-Fastly-1.12 + pathname: F/FA/FASTLY/Net-Fastly-1.12.tar.gz + provides: + Net::Fastly 1.12 + Net::Fastly::Backend undef + Net::Fastly::BelongsToServiceAndVersion undef + Net::Fastly::Client undef + Net::Fastly::Client::UserAgent undef + Net::Fastly::Condition undef + Net::Fastly::Customer undef + Net::Fastly::Director undef + Net::Fastly::Domain undef + Net::Fastly::Healthcheck undef + Net::Fastly::Invoice undef + Net::Fastly::Match undef + Net::Fastly::Model undef + Net::Fastly::Origin undef + Net::Fastly::Service undef + Net::Fastly::Settings undef + Net::Fastly::Stats undef + Net::Fastly::Syslog undef + Net::Fastly::UA undef + Net::Fastly::User undef + Net::Fastly::VCL undef + Net::Fastly::Version undef + requirements: + Class::Accessor::Fast 0 + File::Basename 0 + File::Spec 0 + File::Temp 0 + IO::Socket::SSL != 1.38 + JSON::XS 0 + LWP::Protocol::https 0 + LWP::UserAgent 5.813 + Module::Build::Tiny 0.034 + Test::More 0 + URI 0 + URI::Escape 0 + YAML 0 + Net-GitHub-1.05 + pathname: F/FA/FAYLAND/Net-GitHub-1.05.tar.gz + provides: + Net::GitHub 1.05 + Net::GitHub::V3 1.05 + Net::GitHub::V3::Actions 1.05 + Net::GitHub::V3::Events 1.05 + Net::GitHub::V3::Gists 1.05 + Net::GitHub::V3::GitData 1.05 + Net::GitHub::V3::Gitignore 1.05 + Net::GitHub::V3::Issues 1.05 + Net::GitHub::V3::OAuth 1.05 + Net::GitHub::V3::Orgs 0.60 + Net::GitHub::V3::PullRequests 1.05 + Net::GitHub::V3::Query 1.05 + Net::GitHub::V3::Repos 1.05 + Net::GitHub::V3::ResultSet 1.05 + Net::GitHub::V3::Search 0.68 + Net::GitHub::V3::Users 1.05 + Net::GitHub::V4 1.05 + requirements: + Cache::LRU 0 + ExtUtils::MakeMaker 0 + HTTP::Request 0 + JSON::MaybeXS 0 + LWP::Protocol::https 0 + LWP::UserAgent 0 + MIME::Base64 0 + Moo 0 + Types::Standard 0 + URI 0 + URI::Escape 0 + Net-HTTP-6.23 + pathname: O/OA/OALDERS/Net-HTTP-6.23.tar.gz + provides: + Net::HTTP 6.23 + Net::HTTP::Methods 6.23 + Net::HTTP::NB 6.23 + Net::HTTPS 6.23 + requirements: + Carp 0 + Compress::Raw::Zlib 0 + ExtUtils::MakeMaker 0 + IO::Socket::INET 0 + IO::Uncompress::Gunzip 0 + URI 0 + base 0 + perl 5.006002 + strict 0 + warnings 0 + Net-IP-1.26 + pathname: M/MA/MANU/Net-IP-1.26.tar.gz + provides: + Net::IP 1.26 + requirements: + ExtUtils::MakeMaker 0 + Net-OAuth-0.31 + pathname: R/RR/RRWO/Net-OAuth-0.31.tar.gz + provides: + Net::OAuth 0.31 + Net::OAuth::AccessToken undef + Net::OAuth::AccessTokenRequest undef + Net::OAuth::AccessTokenResponse undef + Net::OAuth::Client 0.31 + Net::OAuth::ConsumerRequest undef + Net::OAuth::Message undef + Net::OAuth::ProtectedResourceRequest undef + Net::OAuth::Request 0.31 + Net::OAuth::RequestTokenRequest undef + Net::OAuth::RequestTokenResponse undef + Net::OAuth::Response undef + Net::OAuth::SignatureMethod::HMAC_SHA1 undef + Net::OAuth::SignatureMethod::HMAC_SHA256 undef + Net::OAuth::SignatureMethod::PLAINTEXT undef + Net::OAuth::SignatureMethod::RSA_SHA1 undef + Net::OAuth::UserAuthRequest undef + Net::OAuth::UserAuthResponse undef + Net::OAuth::V1_0A::AccessTokenRequest undef + Net::OAuth::V1_0A::RequestTokenRequest undef + Net::OAuth::V1_0A::RequestTokenResponse undef + Net::OAuth::V1_0A::UserAuthResponse undef + Net::OAuth::XauthAccessTokenRequest undef + Net::OAuth::YahooAccessTokenRefreshRequest undef + requirements: + Class::Accessor 0.31 + Class::Data::Inheritable 0.06 + Crypt::URandom 0.37 + Digest::SHA 5.47 + Encode 2.35 + ExtUtils::MakeMaker 0 + LWP::UserAgent 1 + Test::More 0.66 + Test::Warn 0.21 + URI 5.15 + Net-SSLeay-1.94 + pathname: C/CH/CHRISN/Net-SSLeay-1.94.tar.gz + provides: + Net::SSLeay 1.94 + Net::SSLeay::Handle 1.94 + requirements: + English 0 + ExtUtils::MakeMaker 0 + File::Spec::Functions 0 + MIME::Base64 0 + Text::Wrap 0 + constant 0 + perl 5.008001 + Number-Compare-0.03 + pathname: R/RC/RCLAMP/Number-Compare-0.03.tar.gz + provides: + Number::Compare 0.03 + requirements: + ExtUtils::MakeMaker 0 + Test::More 0 + Object-Signature-1.08 + pathname: E/ET/ETHER/Object-Signature-1.08.tar.gz + provides: + Object::Signature 1.08 + Object::Signature::File 1.08 + requirements: + Digest::MD5 2.00 + ExtUtils::MakeMaker 0 + Storable 2.11 + base 0 + perl 5.006 + strict 0 + warnings 0 + OrePAN2-0.52 + pathname: O/OA/OALDERS/OrePAN2-0.52.tar.gz + provides: + OrePAN2 0.52 + OrePAN2::Auditor undef + OrePAN2::CLI::Indexer undef + OrePAN2::CLI::Inject undef + OrePAN2::Index undef + OrePAN2::Indexer undef + OrePAN2::Injector undef + OrePAN2::Repository undef + OrePAN2::Repository::Cache undef + requirements: + Archive::Extract 0.72 + Archive::Tar 1.46 + CPAN::Meta 2.131560 + Digest::MD5 0 + ExtUtils::MakeMaker 7.06 + File::Path 0 + File::Spec 0 + File::Temp 0 + File::pushd 0 + File::stat 0 + Getopt::Long 2.39 + HTTP::Tiny 0 + IO::File::AtomicChange 0 + IO::Socket::SSL 1.42 + IO::Uncompress::Gunzip 0 + IO::Zlib 0 + JSON::PP 0 + LWP::UserAgent 0 + List::Compare 0 + MetaCPAN::Client 2.000000 + Module::Build::Tiny 0.035 + Moo 1.007000 + MooX::Options 0 + MooX::StrictConstructor 0 + Parse::CPAN::Meta 1.4414 + Parse::CPAN::Packages::Fast 0.09 + Parse::LocalDistribution 0.14 + Path::Tiny 0 + Pod::Usage 0 + Try::Tiny 0 + Type::Tiny 2.000000 + Types::Path::Tiny 0 + Types::Self 0 + Types::URI 0 + autodie 0 + feature 0 + namespace::clean 0 + parent 0 + perl 5.012000 + version 0.9912 + PAUSE-Permissions-0.17 + pathname: N/NE/NEILB/PAUSE-Permissions-0.17.tar.gz + provides: + PAUSE::Permissions 0.17 + PAUSE::Permissions::Entry 0.17 + PAUSE::Permissions::EntryIterator 0.17 + PAUSE::Permissions::Module 0.17 + PAUSE::Permissions::ModuleIterator 0.17 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + File::HomeDir 0 + File::Spec::Functions 0 + HTTP::Date 0 + HTTP::Tiny 0 + List::Util 1.33 + Moo 0 + MooX::Options 0 + Time::Duration::Parse 0 + autodie 0 + feature 0 + perl 5.010000 + strict 0 + warnings 0 + POSIX-strftime-Compiler-0.46 + pathname: K/KA/KAZEBURO/POSIX-strftime-Compiler-0.46.tar.gz + provides: + POSIX::strftime::Compiler 0.46 + requirements: + Carp 0 + Exporter 0 + Module::Build::Tiny 0.035 + POSIX 0 + Time::Local 0 + perl 5.008001 + PPI-1.281 + pathname: M/MI/MITHALDU/PPI-1.281.tar.gz + provides: + PPI 1.281 + PPI::Cache 1.281 + PPI::Document 1.281 + PPI::Document::File 1.281 + PPI::Document::Fragment 1.281 + PPI::Document::Normalized 1.281 + PPI::Dumper 1.281 + PPI::Element 1.281 + PPI::Exception 1.281 + PPI::Exception::ParserRejection 1.281 + PPI::Find 1.281 + PPI::Lexer 1.281 + PPI::Node 1.281 + PPI::Normal 1.281 + PPI::Normal::Standard 1.281 + PPI::Singletons 1.281 + PPI::Statement 1.281 + PPI::Statement::Break 1.281 + PPI::Statement::Compound 1.281 + PPI::Statement::Data 1.281 + PPI::Statement::End 1.281 + PPI::Statement::Expression 1.281 + PPI::Statement::Given 1.281 + PPI::Statement::Include 1.281 + PPI::Statement::Include::Perl6 1.281 + PPI::Statement::Null 1.281 + PPI::Statement::Package 1.281 + PPI::Statement::Scheduled 1.281 + PPI::Statement::Sub 1.281 + PPI::Statement::Unknown 1.281 + PPI::Statement::UnmatchedBrace 1.281 + PPI::Statement::Variable 1.281 + PPI::Statement::When 1.281 + PPI::Structure 1.281 + PPI::Structure::Block 1.281 + PPI::Structure::Condition 1.281 + PPI::Structure::Constructor 1.281 + PPI::Structure::For 1.281 + PPI::Structure::Given 1.281 + PPI::Structure::List 1.281 + PPI::Structure::Signature 1.281 + PPI::Structure::Subscript 1.281 + PPI::Structure::Unknown 1.281 + PPI::Structure::When 1.281 + PPI::Token 1.281 + PPI::Token::ArrayIndex 1.281 + PPI::Token::Attribute 1.281 + PPI::Token::BOM 1.281 + PPI::Token::Cast 1.281 + PPI::Token::Comment 1.281 + PPI::Token::DashedWord 1.281 + PPI::Token::Data 1.281 + PPI::Token::End 1.281 + PPI::Token::HereDoc 1.281 + PPI::Token::Label 1.281 + PPI::Token::Magic 1.281 + PPI::Token::Number 1.281 + PPI::Token::Number::Binary 1.281 + PPI::Token::Number::Exp 1.281 + PPI::Token::Number::Float 1.281 + PPI::Token::Number::Hex 1.281 + PPI::Token::Number::Octal 1.281 + PPI::Token::Number::Version 1.281 + PPI::Token::Operator 1.281 + PPI::Token::Pod 1.281 + PPI::Token::Prototype 1.281 + PPI::Token::Quote 1.281 + PPI::Token::Quote::Double 1.281 + PPI::Token::Quote::Interpolate 1.281 + PPI::Token::Quote::Literal 1.281 + PPI::Token::Quote::Single 1.281 + PPI::Token::QuoteLike 1.281 + PPI::Token::QuoteLike::Backtick 1.281 + PPI::Token::QuoteLike::Command 1.281 + PPI::Token::QuoteLike::Readline 1.281 + PPI::Token::QuoteLike::Regexp 1.281 + PPI::Token::QuoteLike::Words 1.281 + PPI::Token::Regexp 1.281 + PPI::Token::Regexp::Match 1.281 + PPI::Token::Regexp::Substitute 1.281 + PPI::Token::Regexp::Transliterate 1.281 + PPI::Token::Separator 1.281 + PPI::Token::Structure 1.281 + PPI::Token::Symbol 1.281 + PPI::Token::Unknown 1.281 + PPI::Token::Whitespace 1.281 + PPI::Token::Word 1.281 + PPI::Tokenizer 1.281 + PPI::Transform 1.281 + PPI::Transform::UpdateCopyright 1.281 + PPI::Util 1.281 + PPI::XSAccessor 1.281 + requirements: + Carp 0 + Clone 0.30 + Digest::MD5 2.35 + Exporter 0 + ExtUtils::MakeMaker 0 + File::Path 0 + File::Spec 0.84 + List::Util 1.33 + Params::Util 1.00 + Safe::Isa 0 + Scalar::Util 0 + Storable 2.17 + Task::Weaken 0 + YAML::PP 0 + constant 0 + if 0 + overload 0 + perl 5.006 + strict 0 + version 0.77 + PPIx-QuoteLike-0.023 + pathname: W/WY/WYANT/PPIx-QuoteLike-0.023.tar.gz + provides: + PPIx::QuoteLike 0.023 + PPIx::QuoteLike::Constant 0.023 + PPIx::QuoteLike::Dumper 0.023 + PPIx::QuoteLike::Token 0.023 + PPIx::QuoteLike::Token::Control 0.023 + PPIx::QuoteLike::Token::Delimiter 0.023 + PPIx::QuoteLike::Token::Interpolation 0.023 + PPIx::QuoteLike::Token::String 0.023 + PPIx::QuoteLike::Token::Structure 0.023 + PPIx::QuoteLike::Token::Unknown 0.023 + PPIx::QuoteLike::Token::Whitespace 0.023 + PPIx::QuoteLike::Utils 0.023 + requirements: + Carp 0 + Encode 0 + Exporter 0 + List::Util 0 + PPI::Document 1.238 + PPI::Dumper 1.238 + Readonly 0 + Scalar::Util 0 + Test::More 0.88 + base 0 + charnames 0 + constant 0 + lib 0 + perl 5.006 + re 0 + strict 0 + warnings 0 + PPIx-Regexp-0.088 + pathname: W/WY/WYANT/PPIx-Regexp-0.088.tar.gz + provides: + PPIx::Regexp 0.088 + PPIx::Regexp::Constant 0.085_04 + PPIx::Regexp::Constant::Inf 0.088 + PPIx::Regexp::Dumper 0.088 + PPIx::Regexp::Element 0.088 + PPIx::Regexp::Lexer 0.088 + PPIx::Regexp::Node 0.088 + PPIx::Regexp::Node::Range 0.088 + PPIx::Regexp::Node::Unknown 0.088 + PPIx::Regexp::Structure 0.088 + PPIx::Regexp::Structure::Assertion 0.088 + PPIx::Regexp::Structure::Atomic_Script_Run 0.088 + PPIx::Regexp::Structure::BranchReset 0.088 + PPIx::Regexp::Structure::Capture 0.088 + PPIx::Regexp::Structure::CharClass 0.088 + PPIx::Regexp::Structure::Code 0.088 + PPIx::Regexp::Structure::Main 0.088 + PPIx::Regexp::Structure::Modifier 0.088 + PPIx::Regexp::Structure::NamedCapture 0.088 + PPIx::Regexp::Structure::Quantifier 0.088 + PPIx::Regexp::Structure::RegexSet 0.088 + PPIx::Regexp::Structure::Regexp 0.088 + PPIx::Regexp::Structure::Replacement 0.088 + PPIx::Regexp::Structure::Script_Run 0.088 + PPIx::Regexp::Structure::Subexpression 0.088 + PPIx::Regexp::Structure::Switch 0.088 + PPIx::Regexp::Structure::Unknown 0.088 + PPIx::Regexp::Support 0.088 + PPIx::Regexp::Token 0.088 + PPIx::Regexp::Token::Assertion 0.088 + PPIx::Regexp::Token::Backreference 0.088 + PPIx::Regexp::Token::Backtrack 0.088 + PPIx::Regexp::Token::CharClass 0.088 + PPIx::Regexp::Token::CharClass::POSIX 0.088 + PPIx::Regexp::Token::CharClass::POSIX::Unknown 0.088 + PPIx::Regexp::Token::CharClass::Simple 0.088 + PPIx::Regexp::Token::Code 0.088 + PPIx::Regexp::Token::Comment 0.088 + PPIx::Regexp::Token::Condition 0.088 + PPIx::Regexp::Token::Control 0.088 + PPIx::Regexp::Token::Delimiter 0.088 + PPIx::Regexp::Token::Greediness 0.088 + PPIx::Regexp::Token::GroupType 0.088 + PPIx::Regexp::Token::GroupType::Assertion 0.088 + PPIx::Regexp::Token::GroupType::Atomic_Script_Run 0.088 + PPIx::Regexp::Token::GroupType::BranchReset 0.088 + PPIx::Regexp::Token::GroupType::Code 0.088 + PPIx::Regexp::Token::GroupType::Modifier 0.088 + PPIx::Regexp::Token::GroupType::NamedCapture 0.088 + PPIx::Regexp::Token::GroupType::Script_Run 0.088 + PPIx::Regexp::Token::GroupType::Subexpression 0.088 + PPIx::Regexp::Token::GroupType::Switch 0.088 + PPIx::Regexp::Token::Interpolation 0.088 + PPIx::Regexp::Token::Literal 0.088 + PPIx::Regexp::Token::Modifier 0.088 + PPIx::Regexp::Token::NoOp 0.088 + PPIx::Regexp::Token::Operator 0.088 + PPIx::Regexp::Token::Quantifier 0.088 + PPIx::Regexp::Token::Recursion 0.088 + PPIx::Regexp::Token::Reference 0.088 + PPIx::Regexp::Token::Structure 0.088 + PPIx::Regexp::Token::Unknown 0.088 + PPIx::Regexp::Token::Unmatched 0.088 + PPIx::Regexp::Token::Whitespace 0.088 + PPIx::Regexp::Tokenizer 0.088 + PPIx::Regexp::Util 0.088 + requirements: + Carp 0 + Encode 0 + Exporter 0 + List::Util 0 + PPI::Document 1.238 + PPI::Dumper 1.238 + Scalar::Util 0 + Task::Weaken 0 + Test::More 0.88 + base 0 + charnames 0 + constant 0 + lib 0 + overload 0 + perl 5.006 + strict 0 + warnings 0 + PPIx-Utils-0.003 + pathname: D/DB/DBOOK/PPIx-Utils-0.003.tar.gz + provides: + PPIx::Utils 0.003 + PPIx::Utils::Classification 0.003 + PPIx::Utils::Language 0.003 + PPIx::Utils::Traversal 0.003 + requirements: + B::Keywords 1.09 + Exporter 0 + ExtUtils::MakeMaker 0 + PPI 1.250 + Scalar::Util 0 + perl 5.006 + Package-DeprecationManager-0.18 + pathname: D/DR/DROLSKY/Package-DeprecationManager-0.18.tar.gz + provides: + Package::DeprecationManager 0.18 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + List::Util 1.33 + Package::Stash 0 + Params::Util 0 + Sub::Install 0 + Sub::Util 0 + strict 0 + warnings 0 + Package-Stash-0.40 + pathname: E/ET/ETHER/Package-Stash-0.40.tar.gz + provides: + Package::Stash 0.40 + Package::Stash::PP 0.40 + requirements: + B 0 + Carp 0 + Dist::CheckConflicts 0.02 + ExtUtils::MakeMaker 0 + Getopt::Long 0 + Module::Implementation 0.06 + Package::Stash::XS 0.26 + Scalar::Util 0 + Symbol 0 + Text::ParseWords 0 + constant 0 + perl 5.008001 + strict 0 + warnings 0 + Package-Stash-XS-0.30 + pathname: E/ET/ETHER/Package-Stash-XS-0.30.tar.gz + provides: + Package::Stash::XS 0.30 + requirements: + ExtUtils::MakeMaker 0 + XSLoader 0 + perl 5.008001 + strict 0 + warnings 0 + Parallel-ForkManager-2.03 + pathname: Y/YA/YANICK/Parallel-ForkManager-2.03.tar.gz + provides: + Parallel::ForkManager 2.03 + Parallel::ForkManager::Child 2.03 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + File::Path 0 + File::Spec 0 + File::Temp 0 + Moo 1.001000 + Moo::Role 0 + POSIX 0 + Storable 0 + perl 5.006 + strict 0 + warnings 0 + Params-Util-1.102 + pathname: R/RE/REHSACK/Params-Util-1.102.tar.gz + provides: + Params::Util 1.102 + Params::Util::PP 1.102 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Copy 0 + File::Path 0 + File::Spec 0 + IPC::Cmd 0 + Scalar::Util 1.18 + XSLoader 0.22 + parent 0 + Params-Validate-1.31 + pathname: D/DR/DROLSKY/Params-Validate-1.31.tar.gz + provides: + Params::Validate 1.31 + Params::Validate::Constants 1.31 + Params::Validate::PP 1.31 + Params::Validate::XS 1.31 + requirements: + Carp 0 + Exporter 0 + ExtUtils::CBuilder 0 + Module::Build 0.4227 + Module::Implementation 0 + Scalar::Util 1.10 + XSLoader 0 + perl 5.008001 + strict 0 + vars 0 + warnings 0 + Params-ValidationCompiler-0.31 + pathname: D/DR/DROLSKY/Params-ValidationCompiler-0.31.tar.gz + provides: + Params::ValidationCompiler 0.31 + Params::ValidationCompiler::Compiler 0.31 + Params::ValidationCompiler::Exceptions 0.31 + requirements: + B 0 + Carp 0 + Eval::Closure 0 + Exception::Class 0 + Exporter 0 + ExtUtils::MakeMaker 0 + List::Util 1.29 + Scalar::Util 0 + overload 0 + strict 0 + warnings 0 + Parse-CPAN-Packages-Fast-0.09 + pathname: S/SR/SREZIC/Parse-CPAN-Packages-Fast-0.09.tar.gz + provides: + Parse::CPAN::Packages::Fast 0.09 + Parse::CPAN::Packages::Fast::Distribution 0.09 + Parse::CPAN::Packages::Fast::Package 0.09 + requirements: + CPAN::DistnameInfo 0 + CPAN::Version 0 + ExtUtils::MakeMaker 0 + IO::Uncompress::Gunzip 0 + Parse-LocalDistribution-0.20 + pathname: I/IS/ISHIGAKI/Parse-LocalDistribution-0.20.tar.gz + provides: + Parse::LocalDistribution 0.20 + requirements: + ExtUtils::MakeMaker 0 + ExtUtils::MakeMaker::CPANfile 0.09 + File::Find 0 + File::Spec 0 + List::Util 0 + Parse::CPAN::Meta 0 + Parse::PMFile 0.37 + Parse-PMFile-0.47 + pathname: I/IS/ISHIGAKI/Parse-PMFile-0.47.tar.gz + provides: + Parse::PMFile 0.47 + requirements: + Dumpvalue 0 + ExtUtils::MakeMaker 0 + ExtUtils::MakeMaker::CPANfile 0.09 + File::Spec 0 + JSON::PP 2.00 + Safe 0 + version 0.83 + Path-Class-0.37 + pathname: K/KW/KWILLIAMS/Path-Class-0.37.tar.gz + provides: + Path::Class 0.37 + Path::Class::Dir 0.37 + Path::Class::Entity 0.37 + Path::Class::File 0.37 + requirements: + Carp 0 + Cwd 0 + Exporter 0 + ExtUtils::MakeMaker 6.30 + File::Copy 0 + File::Path 0 + File::Spec 3.26 + File::Temp 0 + File::stat 0 + IO::Dir 0 + IO::File 0 + Module::Build 0.3601 + Perl::OSType 0 + Scalar::Util 0 + overload 0 + parent 0 + strict 0 + Path-Iterator-Rule-1.015 + pathname: D/DA/DAGOLDEN/Path-Iterator-Rule-1.015.tar.gz + provides: + PIR 1.015 + Path::Iterator::Rule 1.015 + requirements: + Carp 0 + ExtUtils::MakeMaker 6.17 + File::Basename 0 + File::Spec 0 + List::Util 0 + Number::Compare 0.02 + Scalar::Util 0 + Text::Glob 0 + Try::Tiny 0 + if 0 + perl 5.008001 + strict 0 + warnings 0 + warnings::register 0 + Path-Tiny-0.148 + pathname: D/DA/DAGOLDEN/Path-Tiny-0.148.tar.gz + provides: + Path::Tiny 0.148 + Path::Tiny::Error 0.148 + requirements: + Carp 0 + Cwd 0 + Digest 1.03 + Digest::SHA 5.45 + Encode 0 + Exporter 5.57 + ExtUtils::MakeMaker 6.17 + Fcntl 0 + File::Compare 0 + File::Copy 0 + File::Glob 0 + File::Path 2.07 + File::Spec 0.86 + File::Temp 0.19 + File::stat 0 + constant 0 + overload 0 + perl 5.008001 + strict 0 + warnings 0 + warnings::register 0 + Perl-Critic-1.156 + pathname: P/PE/PETDANCE/Perl-Critic-1.156.tar.gz + provides: + Perl::Critic 1.156 + Perl::Critic::Annotation 1.156 + Perl::Critic::Command 1.156 + Perl::Critic::Config 1.156 + Perl::Critic::Document 1.156 + Perl::Critic::Exception 1.156 + Perl::Critic::Exception::AggregateConfiguration 1.156 + Perl::Critic::Exception::Configuration 1.156 + Perl::Critic::Exception::Configuration::Generic 1.156 + Perl::Critic::Exception::Configuration::NonExistentPolicy 1.156 + Perl::Critic::Exception::Configuration::Option 1.156 + Perl::Critic::Exception::Configuration::Option::Global 1.156 + Perl::Critic::Exception::Configuration::Option::Global::ExtraParameter 1.156 + Perl::Critic::Exception::Configuration::Option::Global::ParameterValue 1.156 + Perl::Critic::Exception::Configuration::Option::Policy 1.156 + Perl::Critic::Exception::Configuration::Option::Policy::ExtraParameter 1.156 + Perl::Critic::Exception::Configuration::Option::Policy::ParameterValue 1.156 + Perl::Critic::Exception::Fatal 1.156 + Perl::Critic::Exception::Fatal::Generic 1.156 + Perl::Critic::Exception::Fatal::Internal 1.156 + Perl::Critic::Exception::Fatal::PolicyDefinition 1.156 + Perl::Critic::Exception::IO 1.156 + Perl::Critic::Exception::Parse 1.156 + Perl::Critic::OptionsProcessor 1.156 + Perl::Critic::Policy 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitBooleanGrep 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitComplexMappings 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitLvalueSubstr 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitReverseSortBlock 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitShiftRef 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitSleepViaSelect 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitStringyEval 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitStringySplit 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitUniversalCan 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitUniversalIsa 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitUselessTopic 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitVoidGrep 1.156 + Perl::Critic::Policy::BuiltinFunctions::ProhibitVoidMap 1.156 + Perl::Critic::Policy::BuiltinFunctions::RequireBlockGrep 1.156 + Perl::Critic::Policy::BuiltinFunctions::RequireBlockMap 1.156 + Perl::Critic::Policy::BuiltinFunctions::RequireGlobFunction 1.156 + Perl::Critic::Policy::BuiltinFunctions::RequireSimpleSortBlock 1.156 + Perl::Critic::Policy::ClassHierarchies::ProhibitAutoloading 1.156 + Perl::Critic::Policy::ClassHierarchies::ProhibitExplicitISA 1.156 + Perl::Critic::Policy::ClassHierarchies::ProhibitOneArgBless 1.156 + Perl::Critic::Policy::CodeLayout::ProhibitHardTabs 1.156 + Perl::Critic::Policy::CodeLayout::ProhibitParensWithBuiltins 1.156 + Perl::Critic::Policy::CodeLayout::ProhibitQuotedWordLists 1.156 + Perl::Critic::Policy::CodeLayout::ProhibitTrailingWhitespace 1.156 + Perl::Critic::Policy::CodeLayout::RequireConsistentNewlines 1.156 + Perl::Critic::Policy::CodeLayout::RequireTidyCode 1.156 + Perl::Critic::Policy::CodeLayout::RequireTrailingCommas 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitCStyleForLoops 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitCascadingIfElse 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitDeepNests 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitLabelsWithSpecialBlockNames 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitMutatingListFunctions 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitNegativeExpressionsInUnlessAndUntilConditions 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitPostfixControls 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitUnlessBlocks 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitUnreachableCode 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitUntilBlocks 1.156 + Perl::Critic::Policy::ControlStructures::ProhibitYadaOperator 1.156 + Perl::Critic::Policy::Documentation::PodSpelling 1.156 + Perl::Critic::Policy::Documentation::RequirePackageMatchesPodName 1.156 + Perl::Critic::Policy::Documentation::RequirePodAtEnd 1.156 + Perl::Critic::Policy::Documentation::RequirePodSections 1.156 + Perl::Critic::Policy::ErrorHandling::RequireCarping 1.156 + Perl::Critic::Policy::ErrorHandling::RequireCheckingReturnValueOfEval 1.156 + Perl::Critic::Policy::InputOutput::ProhibitBacktickOperators 1.156 + Perl::Critic::Policy::InputOutput::ProhibitBarewordDirHandles 1.156 + Perl::Critic::Policy::InputOutput::ProhibitBarewordFileHandles 1.156 + Perl::Critic::Policy::InputOutput::ProhibitExplicitStdin 1.156 + Perl::Critic::Policy::InputOutput::ProhibitInteractiveTest 1.156 + Perl::Critic::Policy::InputOutput::ProhibitJoinedReadline 1.156 + Perl::Critic::Policy::InputOutput::ProhibitOneArgSelect 1.156 + Perl::Critic::Policy::InputOutput::ProhibitReadlineInForLoop 1.156 + Perl::Critic::Policy::InputOutput::ProhibitTwoArgOpen 1.156 + Perl::Critic::Policy::InputOutput::RequireBracedFileHandleWithPrint 1.156 + Perl::Critic::Policy::InputOutput::RequireBriefOpen 1.156 + Perl::Critic::Policy::InputOutput::RequireCheckedClose 1.156 + Perl::Critic::Policy::InputOutput::RequireCheckedOpen 1.156 + Perl::Critic::Policy::InputOutput::RequireCheckedSyscalls 1.156 + Perl::Critic::Policy::InputOutput::RequireEncodingWithUTF8Layer 1.156 + Perl::Critic::Policy::Miscellanea::ProhibitFormats 1.156 + Perl::Critic::Policy::Miscellanea::ProhibitTies 1.156 + Perl::Critic::Policy::Miscellanea::ProhibitUnrestrictedNoCritic 1.156 + Perl::Critic::Policy::Miscellanea::ProhibitUselessNoCritic 1.156 + Perl::Critic::Policy::Modules::ProhibitAutomaticExportation 1.156 + Perl::Critic::Policy::Modules::ProhibitConditionalUseStatements 1.156 + Perl::Critic::Policy::Modules::ProhibitEvilModules 1.156 + Perl::Critic::Policy::Modules::ProhibitExcessMainComplexity 1.156 + Perl::Critic::Policy::Modules::ProhibitMultiplePackages 1.156 + Perl::Critic::Policy::Modules::RequireBarewordIncludes 1.156 + Perl::Critic::Policy::Modules::RequireEndWithOne 1.156 + Perl::Critic::Policy::Modules::RequireExplicitPackage 1.156 + Perl::Critic::Policy::Modules::RequireFilenameMatchesPackage 1.156 + Perl::Critic::Policy::Modules::RequireNoMatchVarsWithUseEnglish 1.156 + Perl::Critic::Policy::Modules::RequireVersionVar 1.156 + Perl::Critic::Policy::NamingConventions::Capitalization 1.156 + Perl::Critic::Policy::NamingConventions::ProhibitAmbiguousNames 1.156 + Perl::Critic::Policy::Objects::ProhibitIndirectSyntax 1.156 + Perl::Critic::Policy::References::ProhibitDoubleSigils 1.156 + Perl::Critic::Policy::RegularExpressions::ProhibitCaptureWithoutTest 1.156 + Perl::Critic::Policy::RegularExpressions::ProhibitComplexRegexes 1.156 + Perl::Critic::Policy::RegularExpressions::ProhibitEnumeratedClasses 1.156 + Perl::Critic::Policy::RegularExpressions::ProhibitEscapedMetacharacters 1.156 + Perl::Critic::Policy::RegularExpressions::ProhibitFixedStringMatches 1.156 + Perl::Critic::Policy::RegularExpressions::ProhibitSingleCharAlternation 1.156 + Perl::Critic::Policy::RegularExpressions::ProhibitUnusedCapture 1.156 + Perl::Critic::Policy::RegularExpressions::ProhibitUnusualDelimiters 1.156 + Perl::Critic::Policy::RegularExpressions::ProhibitUselessTopic 1.156 + Perl::Critic::Policy::RegularExpressions::RequireBracesForMultiline 1.156 + Perl::Critic::Policy::RegularExpressions::RequireDotMatchAnything 1.156 + Perl::Critic::Policy::RegularExpressions::RequireExtendedFormatting 1.156 + Perl::Critic::Policy::RegularExpressions::RequireLineBoundaryMatching 1.156 + Perl::Critic::Policy::Subroutines::ProhibitAmpersandSigils 1.156 + Perl::Critic::Policy::Subroutines::ProhibitBuiltinHomonyms 1.156 + Perl::Critic::Policy::Subroutines::ProhibitExcessComplexity 1.156 + Perl::Critic::Policy::Subroutines::ProhibitExplicitReturnUndef 1.156 + Perl::Critic::Policy::Subroutines::ProhibitManyArgs 1.156 + Perl::Critic::Policy::Subroutines::ProhibitNestedSubs 1.156 + Perl::Critic::Policy::Subroutines::ProhibitReturnSort 1.156 + Perl::Critic::Policy::Subroutines::ProhibitSubroutinePrototypes 1.156 + Perl::Critic::Policy::Subroutines::ProhibitUnusedPrivateSubroutines 1.156 + Perl::Critic::Policy::Subroutines::ProtectPrivateSubs 1.156 + Perl::Critic::Policy::Subroutines::RequireArgUnpacking 1.156 + Perl::Critic::Policy::Subroutines::RequireFinalReturn 1.156 + Perl::Critic::Policy::TestingAndDebugging::ProhibitNoStrict 1.156 + Perl::Critic::Policy::TestingAndDebugging::ProhibitNoWarnings 1.156 + Perl::Critic::Policy::TestingAndDebugging::ProhibitProlongedStrictureOverride 1.156 + Perl::Critic::Policy::TestingAndDebugging::RequireTestLabels 1.156 + Perl::Critic::Policy::TestingAndDebugging::RequireUseStrict 1.156 + Perl::Critic::Policy::TestingAndDebugging::RequireUseWarnings 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitCommaSeparatedStatements 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitComplexVersion 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitConstantPragma 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitEmptyQuotes 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitEscapedCharacters 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitImplicitNewlines 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitInterpolationOfLiterals 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitLeadingZeros 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitLongChainsOfMethodCalls 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitMagicNumbers 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitMismatchedOperators 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitMixedBooleanOperators 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitNoisyQuotes 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitQuotesAsQuotelikeOperatorDelimiters 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitSpecialLiteralHeredocTerminator 1.156 + Perl::Critic::Policy::ValuesAndExpressions::ProhibitVersionStrings 1.156 + Perl::Critic::Policy::ValuesAndExpressions::RequireConstantVersion 1.156 + Perl::Critic::Policy::ValuesAndExpressions::RequireInterpolationOfMetachars 1.156 + Perl::Critic::Policy::ValuesAndExpressions::RequireNumberSeparators 1.156 + Perl::Critic::Policy::ValuesAndExpressions::RequireQuotedHeredocTerminator 1.156 + Perl::Critic::Policy::ValuesAndExpressions::RequireUpperCaseHeredocTerminator 1.156 + Perl::Critic::Policy::Variables::ProhibitAugmentedAssignmentInDeclaration 1.156 + Perl::Critic::Policy::Variables::ProhibitConditionalDeclarations 1.156 + Perl::Critic::Policy::Variables::ProhibitEvilVariables 1.156 + Perl::Critic::Policy::Variables::ProhibitLocalVars 1.156 + Perl::Critic::Policy::Variables::ProhibitMatchVars 1.156 + Perl::Critic::Policy::Variables::ProhibitPackageVars 1.156 + Perl::Critic::Policy::Variables::ProhibitPerl4PackageNames 1.156 + Perl::Critic::Policy::Variables::ProhibitPunctuationVars 1.156 + Perl::Critic::Policy::Variables::ProhibitReusedNames 1.156 + Perl::Critic::Policy::Variables::ProhibitUnusedVariables 1.156 + Perl::Critic::Policy::Variables::ProtectPrivateVars 1.156 + Perl::Critic::Policy::Variables::RequireInitializationForLocalVars 1.156 + Perl::Critic::Policy::Variables::RequireLexicalLoopIterators 1.156 + Perl::Critic::Policy::Variables::RequireLocalizedPunctuationVars 1.156 + Perl::Critic::Policy::Variables::RequireNegativeIndices 1.156 + Perl::Critic::PolicyConfig 1.156 + Perl::Critic::PolicyFactory 1.156 + Perl::Critic::PolicyListing 1.156 + Perl::Critic::PolicyParameter 1.156 + Perl::Critic::PolicyParameter::Behavior 1.156 + Perl::Critic::PolicyParameter::Behavior::Boolean 1.156 + Perl::Critic::PolicyParameter::Behavior::Enumeration 1.156 + Perl::Critic::PolicyParameter::Behavior::Integer 1.156 + Perl::Critic::PolicyParameter::Behavior::String 1.156 + Perl::Critic::PolicyParameter::Behavior::StringList 1.156 + Perl::Critic::ProfilePrototype 1.156 + Perl::Critic::Statistics 1.156 + Perl::Critic::TestUtils 1.156 + Perl::Critic::Theme 1.156 + Perl::Critic::ThemeListing 1.156 + Perl::Critic::UserProfile 1.156 + Perl::Critic::Utils 1.156 + Perl::Critic::Utils::Constants 1.156 + Perl::Critic::Utils::McCabe 1.156 + Perl::Critic::Utils::POD 1.156 + Perl::Critic::Utils::PPI 1.156 + Perl::Critic::Utils::Perl 1.156 + Perl::Critic::Violation 1.156 + Test::Perl::Critic::Policy 1.156 + requirements: + B::Keywords 1.23 + Carp 0 + Config::Tiny 2 + English 0 + Exception::Class 1.23 + Exporter 5.63 + File::Basename 0 + File::Find 0 + File::Path 0 + File::Spec 0 + File::Spec::Unix 0 + File::Temp 0 + File::Which 0 + Getopt::Long 0 + List::SomeUtils 0.55 + List::Util 0 + Module::Build 0.4204 + Module::Pluggable 3.1 + PPI 1.277 + PPI::Document 1.277 + PPI::Document::File 1.277 + PPI::Node 1.277 + PPI::Token::Quote::Single 1.277 + PPI::Token::Whitespace 1.277 + PPIx::QuoteLike 0 + PPIx::Regexp 0.027 + PPIx::Regexp::Util 0.068 + PPIx::Utils::Traversal 0.003 + Perl::Tidy 0 + Pod::PlainText 0 + Pod::Select 0 + Pod::Spell 1 + Pod::Usage 0 + Readonly 2 + Scalar::Util 0 + String::Format 1.18 + Term::ANSIColor 2.02 + Test::Builder 0.92 + Text::ParseWords 3 + base 0 + charnames 0 + lib 0 + overload 0 + parent 0 + perl 5.010001 + strict 0 + version 0.77 + warnings 0 + Perl-Tidy-20240511 + pathname: S/SH/SHANCOCK/Perl-Tidy-20240511.tar.gz + provides: + Perl::Tidy 20240511 + Perl::Tidy::Debugger 20240511 + Perl::Tidy::Diagnostics 20240511 + Perl::Tidy::FileWriter 20240511 + Perl::Tidy::Formatter 20240511 + Perl::Tidy::HtmlWriter 20240511 + Perl::Tidy::IOScalar 20240511 + Perl::Tidy::IOScalarArray 20240511 + Perl::Tidy::IndentationItem 20240511 + Perl::Tidy::Logger 20240511 + Perl::Tidy::Tokenizer 20240511 + Perl::Tidy::VerticalAligner 20240511 + Perl::Tidy::VerticalAligner::Alignment 20240511 + Perl::Tidy::VerticalAligner::Line 20240511 + requirements: + ExtUtils::MakeMaker 0 + perl 5.008 + PerlIO-gzip-0.20 + pathname: N/NW/NWCLARK/PerlIO-gzip-0.20.tar.gz + provides: + PerlIO::gzip 0.20 + requirements: + ExtUtils::MakeMaker 0 + PerlIO-utf8_strict-0.010 + pathname: L/LE/LEONT/PerlIO-utf8_strict-0.010.tar.gz + provides: + PerlIO::utf8_strict 0.010 + requirements: + ExtUtils::MakeMaker 0 + XSLoader 0 + perl 5.008 + strict 0 + warnings 0 + Plack-1.0051 + pathname: M/MI/MIYAGAWA/Plack-1.0051.tar.gz + provides: + HTTP::Message::PSGI undef + HTTP::Server::PSGI undef + Plack 1.0051 + Plack::App::CGIBin undef + Plack::App::Cascade undef + Plack::App::Directory undef + Plack::App::File undef + Plack::App::PSGIBin undef + Plack::App::URLMap undef + Plack::App::WrapCGI undef + Plack::Builder undef + Plack::Component undef + Plack::HTTPParser undef + Plack::HTTPParser::PP undef + Plack::Handler undef + Plack::Handler::Apache1 undef + Plack::Handler::Apache2 undef + Plack::Handler::Apache2::Registry undef + Plack::Handler::CGI undef + Plack::Handler::CGI::Writer undef + Plack::Handler::FCGI undef + Plack::Handler::HTTP::Server::PSGI undef + Plack::Handler::Standalone undef + Plack::LWPish undef + Plack::Loader undef + Plack::Loader::Delayed undef + Plack::Loader::Restarter undef + Plack::Loader::Shotgun undef + Plack::MIME undef + Plack::Middleware undef + Plack::Middleware::AccessLog undef + Plack::Middleware::AccessLog::Timed undef + Plack::Middleware::Auth::Basic undef + Plack::Middleware::BufferedStreaming undef + Plack::Middleware::Chunked undef + Plack::Middleware::Conditional undef + Plack::Middleware::ConditionalGET undef + Plack::Middleware::ContentLength undef + Plack::Middleware::ContentMD5 undef + Plack::Middleware::ErrorDocument undef + Plack::Middleware::HTTPExceptions undef + Plack::Middleware::Head undef + Plack::Middleware::IIS6ScriptNameFix undef + Plack::Middleware::IIS7KeepAliveFix undef + Plack::Middleware::JSONP undef + Plack::Middleware::LighttpdScriptNameFix undef + Plack::Middleware::Lint undef + Plack::Middleware::Log4perl undef + Plack::Middleware::LogDispatch undef + Plack::Middleware::NullLogger undef + Plack::Middleware::RearrangeHeaders undef + Plack::Middleware::Recursive undef + Plack::Middleware::Refresh undef + Plack::Middleware::Runtime undef + Plack::Middleware::SimpleContentFilter undef + Plack::Middleware::SimpleLogger undef + Plack::Middleware::StackTrace undef + Plack::Middleware::Static undef + Plack::Middleware::XFramework undef + Plack::Middleware::XSendfile undef + Plack::Recursive::ForwardRequest undef + Plack::Request 1.0051 + Plack::Request::Upload undef + Plack::Response 1.0051 + Plack::Runner undef + Plack::TempBuffer undef + Plack::Test undef + Plack::Test::MockHTTP undef + Plack::Test::Server undef + Plack::Test::Suite undef + Plack::Util undef + Plack::Util::Accessor undef + Plack::Util::IOWithPath undef + Plack::Util::Prototype undef + requirements: + Apache::LogFormat::Compiler 0.33 + Cookie::Baker 0.07 + Devel::StackTrace 1.23 + Devel::StackTrace::AsHTML 0.11 + ExtUtils::MakeMaker 0 + File::ShareDir 1.00 + File::ShareDir::Install 0.06 + Filesys::Notify::Simple 0 + HTTP::Entity::Parser 0.25 + HTTP::Headers::Fast 0.18 + HTTP::Message 5.814 + HTTP::Tiny 0.034 + Hash::MultiValue 0.05 + Pod::Usage 1.36 + Stream::Buffered 0.02 + Test::TCP 2.15 + Try::Tiny 0 + URI 1.59 + WWW::Form::UrlEncoded 0.23 + parent 0 + perl 5.012000 + Plack-Middleware-FixMissingBodyInRedirect-0.12 + pathname: S/SW/SWEETKID/Plack-Middleware-FixMissingBodyInRedirect-0.12.tar.gz + provides: + Plack::Middleware::FixMissingBodyInRedirect 0.12 + requirements: + ExtUtils::MakeMaker 6.30 + HTML::Entities 0 + Plack::Middleware 0 + Plack::Util 0 + Scalar::Util 0 + parent 0 + strict 0 + warnings 0 + Plack-Middleware-MethodOverride-0.20 + pathname: M/MI/MIYAGAWA/Plack-Middleware-MethodOverride-0.20.tar.gz + provides: + Plack::Middleware::MethodOverride 0.20 + requirements: + ExtUtils::MakeMaker 0 + Plack::Middleware 0 + Plack::Request 0 + Plack::Util::Accessor 0 + parent 0 + perl 5.008001 + strict 0 + warnings 0 + Plack-Middleware-RemoveRedundantBody-0.09 + pathname: S/SW/SWEETKID/Plack-Middleware-RemoveRedundantBody-0.09.tar.gz + provides: + Plack::Middleware::RemoveRedundantBody 0.09 + requirements: + ExtUtils::MakeMaker 0 + Plack::Middleware 0 + Plack::Util 0 + parent 0 + strict 0 + warnings 0 + Plack-Middleware-ReverseProxy-0.16 + pathname: M/MI/MIYAGAWA/Plack-Middleware-ReverseProxy-0.16.tar.gz + provides: + Plack::Middleware::ReverseProxy 0.16 + requirements: + ExtUtils::MakeMaker 6.59 + Plack 0.9988 + Plack::Middleware 0 + Plack::Request 0 + Test::More 0 + parent 0 + perl 5.008001 + Plack-Middleware-Session-0.34 + pathname: M/MI/MIYAGAWA/Plack-Middleware-Session-0.34.tar.gz + provides: + Plack::Middleware::Session 0.34 + Plack::Middleware::Session::Cookie undef + Plack::Session 0.34 + Plack::Session::Cleanup 0.34 + Plack::Session::State 0.34 + Plack::Session::State::Cookie 0.34 + Plack::Session::Store 0.34 + Plack::Session::Store::Cache 0.34 + Plack::Session::Store::DBI 0.34 + Plack::Session::Store::File 0.34 + Plack::Session::Store::Null 0.34 + requirements: + Cookie::Baker 0.12 + Digest::HMAC_SHA1 1.03 + Digest::SHA 0 + Module::Build::Tiny 0.034 + Plack 0.9910 + Plack-Test-ExternalServer-0.02 + pathname: E/ET/ETHER/Plack-Test-ExternalServer-0.02.tar.gz + provides: + Plack::Test::ExternalServer 0.02 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + LWP::UserAgent 0 + URI 0 + perl 5.006 + strict 0 + warnings 0 + Pod-Markdown-3.400 + pathname: R/RW/RWSTAUNER/Pod-Markdown-3.400.tar.gz + provides: + Pod::Markdown 3.400 + Pod::Perldoc::ToMarkdown 3.400 + requirements: + Encode 0 + ExtUtils::MakeMaker 0 + Getopt::Long 0 + Pod::Simple 3.27 + Pod::Simple::Methody 0 + Pod::Usage 0 + URI::Escape 0 + parent 0 + perl 5.008 + strict 0 + warnings 0 + Pod-Simple-3.45 + pathname: K/KH/KHW/Pod-Simple-3.45.tar.gz + provides: + Pod::Simple 3.45 + Pod::Simple::BlackBox 3.45 + Pod::Simple::Checker 3.45 + Pod::Simple::Debug 3.45 + Pod::Simple::DumpAsText 3.45 + Pod::Simple::DumpAsXML 3.45 + Pod::Simple::HTML 3.45 + Pod::Simple::HTMLBatch 3.45 + Pod::Simple::HTMLLegacy 5.01 + Pod::Simple::JustPod undef + Pod::Simple::LinkSection 3.45 + Pod::Simple::Methody 3.45 + Pod::Simple::Progress 3.45 + Pod::Simple::PullParser 3.45 + Pod::Simple::PullParserEndToken 3.45 + Pod::Simple::PullParserStartToken 3.45 + Pod::Simple::PullParserTextToken 3.45 + Pod::Simple::PullParserToken 3.45 + Pod::Simple::RTF 3.45 + Pod::Simple::Search 3.45 + Pod::Simple::SimpleTree 3.45 + Pod::Simple::Text 3.45 + Pod::Simple::TextContent 3.45 + Pod::Simple::TiedOutFH 3.45 + Pod::Simple::Transcode 3.45 + Pod::Simple::TranscodeDumb 3.45 + Pod::Simple::TranscodeSmart 3.45 + Pod::Simple::XHTML 3.45 + Pod::Simple::XMLOutStream 3.45 + requirements: + Carp 0 + Config 0 + Cwd 0 + ExtUtils::MakeMaker 0 + File::Basename 0 + File::Find 0 + File::Spec 0 + Pod::Escapes 1.04 + Symbol 0 + Text::Wrap 98.112902 + if 0 + integer 0 + overload 0 + strict 0 + Pod-Spell-1.27 + pathname: H/HA/HAARG/Pod-Spell-1.27.tar.gz + provides: + Pod::Spell 1.27 + Pod::Wordlist 1.27 + requirements: + Carp 0 + Class::Tiny 0 + ExtUtils::MakeMaker 0 + File::ShareDir 0 + File::ShareDir::Install 0.06 + Lingua::EN::Inflect 0 + POSIX 0 + Pod::Escapes 0 + Pod::Simple 3.27 + Text::Wrap 0 + constant 0 + locale 0 + parent 0 + perl 5.008001 + Readonly-2.05 + pathname: S/SA/SANKO/Readonly-2.05.tar.gz + provides: + Readonly 2.05 + Readonly::Array undef + Readonly::Hash undef + Readonly::Scalar undef + requirements: + Module::Build::Tiny 0.035 + perl 5.005 + Ref-Util-0.204 + pathname: A/AR/ARC/Ref-Util-0.204.tar.gz + provides: + Ref::Util 0.204 + Ref::Util::PP 0.204 + requirements: + Exporter 5.57 + ExtUtils::MakeMaker 0 + Ref::Util::XS 0 + Text::ParseWords 0 + perl 5.006 + Ref-Util-XS-0.117 + pathname: X/XS/XSAWYERX/Ref-Util-XS-0.117.tar.gz + provides: + Ref::Util::XS 0.117 + requirements: + Exporter 5.57 + ExtUtils::MakeMaker 0 + XSLoader 0 + perl 5.006 + Role-Hooks-0.008 + pathname: T/TO/TOBYINK/Role-Hooks-0.008.tar.gz + provides: + Role::Hooks 0.008 + requirements: + Class::Method::Modifiers 0 + ExtUtils::MakeMaker 6.17 + List::Util 1.45 + perl 5.008001 + Role-Tiny-2.002004 + pathname: H/HA/HAARG/Role-Tiny-2.002004.tar.gz + provides: + Role::Tiny 2.002004 + Role::Tiny::With 2.002004 + requirements: + Exporter 5.57 + perl 5.006 + SQL-Abstract-2.000001 + pathname: M/MS/MSTROUT/SQL-Abstract-2.000001.tar.gz + provides: + Chunkstrumenter undef + DBIx::Class::SQLMaker::Role::SQLA2Passthrough undef + SQL::Abstract 2.000001 + SQL::Abstract::Formatter undef + SQL::Abstract::Parts undef + SQL::Abstract::Plugin::BangOverrides undef + SQL::Abstract::Plugin::ExtraClauses undef + SQL::Abstract::Reference undef + SQL::Abstract::Role::Plugin undef + SQL::Abstract::Test undef + SQL::Abstract::Tree undef + requirements: + Exporter 5.57 + ExtUtils::MakeMaker 0 + Hash::Merge 0.12 + List::Util 0 + MRO::Compat 0.12 + Moo 2.000001 + Scalar::Util 0 + Sub::Quote 2.000001 + Test::Builder::Module 0.84 + Test::Deep 0.101 + Text::Balanced 2.00 + perl 5.006 + SQL-Abstract-Pg-1.0 + pathname: S/SR/SRI/SQL-Abstract-Pg-1.0.tar.gz + provides: + SQL::Abstract::Pg 1.0 + requirements: + ExtUtils::MakeMaker 0 + SQL::Abstract 2.0 + perl 5.016 + Safe-Isa-1.000010 + pathname: E/ET/ETHER/Safe-Isa-1.000010.tar.gz + provides: + Safe::Isa 1.000010 + requirements: + Exporter 5.57 + ExtUtils::MakeMaker 0 + Scalar::Util 0 + perl 5.006 + Scalar-List-Utils-1.69 + pathname: P/PE/PEVANS/Scalar-List-Utils-1.69.tar.gz + provides: + List::Util 1.69 + List::Util::XS 1.69 + Scalar::List::Utils 1.69 + Scalar::Util 1.69 + Sub::Util 1.69 + requirements: + ExtUtils::MakeMaker 0 + perl 5.006 + Scope-Guard-0.21 + pathname: C/CH/CHOCOLATE/Scope-Guard-0.21.tar.gz + provides: + Scope::Guard 0.21 + requirements: + ExtUtils::MakeMaker 0 + Test::More 0 + perl 5.006001 + Search-Elasticsearch-8.12 + pathname: E/EZ/EZIMUEL/Search-Elasticsearch-8.12.tar.gz + provides: + Search::Elasticsearch 8.12 + Search::Elasticsearch::Client::8_0 8.12 + Search::Elasticsearch::Client::8_0::Bulk 8.12 + Search::Elasticsearch::Client::8_0::Direct 8.12 + Search::Elasticsearch::Client::8_0::Direct::Autoscaling 8.12 + Search::Elasticsearch::Client::8_0::Direct::CCR 8.12 + Search::Elasticsearch::Client::8_0::Direct::Cat 8.12 + Search::Elasticsearch::Client::8_0::Direct::Cluster 8.12 + Search::Elasticsearch::Client::8_0::Direct::Connector 8.12 + Search::Elasticsearch::Client::8_0::Direct::ConnectorSyncJob 8.12 + Search::Elasticsearch::Client::8_0::Direct::DanglingIndices 8.12 + Search::Elasticsearch::Client::8_0::Direct::Enrich 8.12 + Search::Elasticsearch::Client::8_0::Direct::Eql 8.12 + Search::Elasticsearch::Client::8_0::Direct::Esql 8.12 + Search::Elasticsearch::Client::8_0::Direct::Features 8.12 + Search::Elasticsearch::Client::8_0::Direct::Fleet 8.12 + Search::Elasticsearch::Client::8_0::Direct::Graph 8.12 + Search::Elasticsearch::Client::8_0::Direct::ILM 8.12 + Search::Elasticsearch::Client::8_0::Direct::Indices 8.12 + Search::Elasticsearch::Client::8_0::Direct::Inference 8.12 + Search::Elasticsearch::Client::8_0::Direct::Ingest 8.12 + Search::Elasticsearch::Client::8_0::Direct::License 8.12 + Search::Elasticsearch::Client::8_0::Direct::Logstash 8.12 + Search::Elasticsearch::Client::8_0::Direct::ML 8.12 + Search::Elasticsearch::Client::8_0::Direct::Migration 8.12 + Search::Elasticsearch::Client::8_0::Direct::Monitoring 8.12 + Search::Elasticsearch::Client::8_0::Direct::Nodes 8.12 + Search::Elasticsearch::Client::8_0::Direct::Profiling 8.12 + Search::Elasticsearch::Client::8_0::Direct::QueryRuleset 8.12 + Search::Elasticsearch::Client::8_0::Direct::Rollup 8.12 + Search::Elasticsearch::Client::8_0::Direct::SQL 8.12 + Search::Elasticsearch::Client::8_0::Direct::SSL 8.12 + Search::Elasticsearch::Client::8_0::Direct::SearchApplication 8.12 + Search::Elasticsearch::Client::8_0::Direct::SearchableSnapshots 8.12 + Search::Elasticsearch::Client::8_0::Direct::Security 8.12 + Search::Elasticsearch::Client::8_0::Direct::Shutdown 8.12 + Search::Elasticsearch::Client::8_0::Direct::Simulate 8.12 + Search::Elasticsearch::Client::8_0::Direct::Slm 8.12 + Search::Elasticsearch::Client::8_0::Direct::Snapshot 8.12 + Search::Elasticsearch::Client::8_0::Direct::Synonyms 8.12 + Search::Elasticsearch::Client::8_0::Direct::Tasks 8.12 + Search::Elasticsearch::Client::8_0::Direct::TextStructure 8.12 + Search::Elasticsearch::Client::8_0::Direct::Transform 8.12 + Search::Elasticsearch::Client::8_0::Direct::Watcher 8.12 + Search::Elasticsearch::Client::8_0::Direct::XPack 8.12 + Search::Elasticsearch::Client::8_0::Role::API 8.12 + Search::Elasticsearch::Client::8_0::Role::Bulk 8.12 + Search::Elasticsearch::Client::8_0::Role::Scroll 8.12 + Search::Elasticsearch::Client::8_0::Scroll 8.12 + Search::Elasticsearch::Client::8_0::TestServer 8.12 + Search::Elasticsearch::Cxn::Factory 8.12 + Search::Elasticsearch::Cxn::HTTPTiny 8.12 + Search::Elasticsearch::Cxn::LWP 8.12 + Search::Elasticsearch::CxnPool::Sniff 8.12 + Search::Elasticsearch::CxnPool::Static 8.12 + Search::Elasticsearch::CxnPool::Static::NoPing 8.12 + Search::Elasticsearch::Error 8.12 + Search::Elasticsearch::Logger::LogAny 8.12 + Search::Elasticsearch::Role::API 8.12 + Search::Elasticsearch::Role::Client 8.12 + Search::Elasticsearch::Role::Client::Direct 8.12 + Search::Elasticsearch::Role::Cxn 8.12 + Search::Elasticsearch::Role::CxnPool 8.12 + Search::Elasticsearch::Role::CxnPool::Sniff 8.12 + Search::Elasticsearch::Role::CxnPool::Static 8.12 + Search::Elasticsearch::Role::CxnPool::Static::NoPing 8.12 + Search::Elasticsearch::Role::Is_Sync 8.12 + Search::Elasticsearch::Role::Logger 8.12 + Search::Elasticsearch::Role::Serializer 8.12 + Search::Elasticsearch::Role::Serializer::JSON 8.12 + Search::Elasticsearch::Role::Transport 8.12 + Search::Elasticsearch::Serializer::JSON 8.12 + Search::Elasticsearch::Serializer::JSON::Cpanel 8.12 + Search::Elasticsearch::Serializer::JSON::PP 8.12 + Search::Elasticsearch::Serializer::JSON::XS 8.12 + Search::Elasticsearch::TestServer 8.12 + Search::Elasticsearch::Transport 8.12 + Search::Elasticsearch::Util 8.12 + requirements: + Any::URI::Escape 0 + Data::Dumper 0 + Devel::GlobalDestruction 0 + Encode 0 + ExtUtils::MakeMaker 0 + File::Temp 0 + HTTP::Headers 0 + HTTP::Request 0 + HTTP::Tiny 0.076 + IO::Compress::Deflate 0 + IO::Compress::Gzip 0 + IO::Select 0 + IO::Socket 0 + IO::Uncompress::Gunzip 0 + IO::Uncompress::Inflate 0 + JSON::MaybeXS 1.002002 + JSON::PP 0 + LWP::UserAgent 0 + List::Util 0 + Log::Any 1.02 + Log::Any::Adapter 0 + MIME::Base64 0 + Module::Runtime 0 + Moo 2.001000 + Moo::Role 0 + Net::IP 0 + POSIX 0 + Package::Stash 0.34 + Scalar::Util 0 + Sub::Exporter 0 + Time::HiRes 0 + Try::Tiny 0 + URI 0 + namespace::clean 0 + overload 0 + strict 0 + warnings 0 + Search-Elasticsearch-Client-2_0-6.81 + pathname: E/EZ/EZIMUEL/Search-Elasticsearch-Client-2_0-6.81.tar.gz + provides: + Search::Elasticsearch::Client::2_0 6.81 + Search::Elasticsearch::Client::2_0::Bulk 6.81 + Search::Elasticsearch::Client::2_0::Direct 6.81 + Search::Elasticsearch::Client::2_0::Direct::Cat 6.81 + Search::Elasticsearch::Client::2_0::Direct::Cluster 6.81 + Search::Elasticsearch::Client::2_0::Direct::Indices 6.81 + Search::Elasticsearch::Client::2_0::Direct::Nodes 6.81 + Search::Elasticsearch::Client::2_0::Direct::Snapshot 6.81 + Search::Elasticsearch::Client::2_0::Direct::Tasks 6.81 + Search::Elasticsearch::Client::2_0::Role::API 6.81 + Search::Elasticsearch::Client::2_0::Role::Bulk 6.81 + Search::Elasticsearch::Client::2_0::Role::Scroll 6.81 + Search::Elasticsearch::Client::2_0::Scroll 6.81 + Search::Elasticsearch::Client::2_0::TestServer 6.81 + requirements: + Devel::GlobalDestruction 0 + ExtUtils::MakeMaker 0 + Moo 0 + Moo::Role 0 + Search::Elasticsearch 6.00 + Search::Elasticsearch::Role::API 0 + Search::Elasticsearch::Role::Client::Direct 0 + Search::Elasticsearch::Role::Is_Sync 0 + Search::Elasticsearch::Util 0 + Try::Tiny 0 + namespace::clean 0 + strict 0 + warnings 0 + Sereal-Decoder-5.004 + pathname: Y/YV/YVES/Sereal-Decoder-5.004.tar.gz + provides: + Sereal::Decoder 5.004 + Sereal::Decoder::Constants 5.004 + Sereal::Performance undef + requirements: + Data::Dumper 0 + Devel::CheckLib 1.16 + ExtUtils::MakeMaker 7.0 + ExtUtils::ParseXS 2.21 + File::Find 0 + File::Path 0 + File::Spec 0 + Scalar::Util 0 + Test::Deep 0 + Test::Differences 0 + Test::LongString 0 + Test::More 0.88 + Test::Warn 0 + XSLoader 0 + perl 5.008 + Sereal-Encoder-5.004 + pathname: Y/YV/YVES/Sereal-Encoder-5.004.tar.gz + provides: + Sereal::Encoder 5.004 + Sereal::Encoder::Constants 5.004 + requirements: + Data::Dumper 0 + Devel::CheckLib 1.16 + ExtUtils::MakeMaker 7.0 + ExtUtils::ParseXS 2.21 + File::Find 0 + File::Path 0 + File::Spec 0 + Hash::Util 0 + Scalar::Util 0 + Sereal::Decoder 5.004 + Test::Deep 0 + Test::Differences 0 + Test::LongString 0 + Test::More 0.88 + Test::Warn 0 + XSLoader 0 + perl 5.008 + Sort-Versions-1.62 + pathname: N/NE/NEILB/Sort-Versions-1.62.tar.gz + provides: + Sort::Versions 1.62 + requirements: + Exporter 0 + ExtUtils::MakeMaker 0 + perl 5.006 + strict 0 + warnings 0 + Specio-0.50 + pathname: D/DR/DROLSKY/Specio-0.50.tar.gz + provides: + Specio 0.50 + Specio::Coercion 0.50 + Specio::Constraint::AnyCan 0.50 + Specio::Constraint::AnyDoes 0.50 + Specio::Constraint::AnyIsa 0.50 + Specio::Constraint::Enum 0.50 + Specio::Constraint::Intersection 0.50 + Specio::Constraint::ObjectCan 0.50 + Specio::Constraint::ObjectDoes 0.50 + Specio::Constraint::ObjectIsa 0.50 + Specio::Constraint::Parameterizable 0.50 + Specio::Constraint::Parameterized 0.50 + Specio::Constraint::Role::CanType 0.50 + Specio::Constraint::Role::DoesType 0.50 + Specio::Constraint::Role::Interface 0.50 + Specio::Constraint::Role::IsaType 0.50 + Specio::Constraint::Simple 0.50 + Specio::Constraint::Structurable 0.50 + Specio::Constraint::Structured 0.50 + Specio::Constraint::Union 0.50 + Specio::Declare 0.50 + Specio::DeclaredAt 0.50 + Specio::Exception 0.50 + Specio::Exporter 0.50 + Specio::Helpers 0.50 + Specio::Library::Builtins 0.50 + Specio::Library::Numeric 0.50 + Specio::Library::Perl 0.50 + Specio::Library::String 0.50 + Specio::Library::Structured 0.50 + Specio::Library::Structured::Dict 0.50 + Specio::Library::Structured::Map 0.50 + Specio::Library::Structured::Tuple 0.50 + Specio::OO 0.50 + Specio::PartialDump 0.50 + Specio::Registry 0.50 + Specio::Role::Inlinable 0.50 + Specio::Subs 0.50 + Specio::TypeChecks 0.50 + Test::Specio 0.50 + requirements: + B 0 + Carp 0 + Clone 0 + Devel::StackTrace 0 + Eval::Closure 0 + Exporter 0 + ExtUtils::MakeMaker 0 + IO::File 0 + List::Util 1.33 + MRO::Compat 0 + Module::Runtime 0 + Role::Tiny 1.003003 + Role::Tiny::With 0 + Scalar::Util 0 + Sub::Quote 0 + Test::Fatal 0 + Test::More 0.96 + Try::Tiny 0 + XString 0 + overload 0 + parent 0 + perl 5.008 + re 0 + strict 0 + version 0.83 + warnings 0 + Specio-Library-Path-Tiny-0.05 + pathname: D/DR/DROLSKY/Specio-Library-Path-Tiny-0.05.tar.gz + provides: + Specio::Library::Path::Tiny 0.05 + requirements: + ExtUtils::MakeMaker 0 + Path::Tiny 0.087 + Scalar::Util 0 + Specio 0.29 + Specio::Declare 0 + Specio::Exporter 0 + Specio::Library::Builtins 0 + Specio::PartialDump 0 + overload 0 + parent 0 + strict 0 + warnings 0 + Stream-Buffered-0.03 + pathname: D/DO/DOY/Stream-Buffered-0.03.tar.gz + provides: + Stream::Buffered 0.03 + Stream::Buffered::Auto undef + Stream::Buffered::File undef + Stream::Buffered::PerlIO undef + requirements: + ExtUtils::MakeMaker 6.30 + IO::File 1.14 + String-Format-1.18 + pathname: S/SR/SREZIC/String-Format-1.18.tar.gz + provides: + String::Format 1.18 + requirements: + ExtUtils::MakeMaker 0 + Test::More 0 + String-RewritePrefix-0.009 + pathname: R/RJ/RJBS/String-RewritePrefix-0.009.tar.gz + provides: + String::RewritePrefix 0.009 + requirements: + Carp 0 + ExtUtils::MakeMaker 6.78 + Sub::Exporter 0.972 + perl 5.012 + strict 0 + warnings 0 + Sub-Exporter-0.991 + pathname: R/RJ/RJBS/Sub-Exporter-0.991.tar.gz + provides: + Sub::Exporter 0.991 + Sub::Exporter::Util 0.991 + requirements: + Carp 0 + Data::OptList 0.100 + ExtUtils::MakeMaker 6.78 + Params::Util 0.14 + Sub::Install 0.92 + perl 5.012 + strict 0 + warnings 0 + Sub-Exporter-ForMethods-0.100055 + pathname: R/RJ/RJBS/Sub-Exporter-ForMethods-0.100055.tar.gz + provides: + Sub::Exporter::ForMethods 0.100055 + requirements: + ExtUtils::MakeMaker 6.78 + Scalar::Util 0 + Sub::Exporter 0.978 + Sub::Util 0 + perl 5.012 + strict 0 + warnings 0 + Sub-Exporter-Progressive-0.001013 + pathname: F/FR/FREW/Sub-Exporter-Progressive-0.001013.tar.gz + provides: + Sub::Exporter::Progressive 0.001013 + requirements: + ExtUtils::MakeMaker 0 + Sub-HandlesVia-0.050002 + pathname: T/TO/TOBYINK/Sub-HandlesVia-0.050002.tar.gz + provides: + Sub::HandlesVia 0.050002 + Sub::HandlesVia::CodeGenerator 0.050002 + Sub::HandlesVia::Declare 0.050002 + Sub::HandlesVia::Handler 0.050002 + Sub::HandlesVia::Handler::CodeRef 0.050002 + Sub::HandlesVia::Handler::Traditional 0.050002 + Sub::HandlesVia::HandlerLibrary 0.050002 + Sub::HandlesVia::HandlerLibrary::Array 0.050002 + Sub::HandlesVia::HandlerLibrary::Blessed 0.050002 + Sub::HandlesVia::HandlerLibrary::Bool 0.050002 + Sub::HandlesVia::HandlerLibrary::Code 0.050002 + Sub::HandlesVia::HandlerLibrary::Counter 0.050002 + Sub::HandlesVia::HandlerLibrary::Enum 0.050002 + Sub::HandlesVia::HandlerLibrary::Hash 0.050002 + Sub::HandlesVia::HandlerLibrary::Number 0.050002 + Sub::HandlesVia::HandlerLibrary::Scalar 0.050002 + Sub::HandlesVia::HandlerLibrary::String 0.050002 + Sub::HandlesVia::Mite undef + Sub::HandlesVia::Toolkit 0.050002 + Sub::HandlesVia::Toolkit::Mite 0.050002 + Sub::HandlesVia::Toolkit::Moo 0.050002 + Sub::HandlesVia::Toolkit::Moose 0.050002 + Sub::HandlesVia::Toolkit::Moose::PackageTrait 0.050002 + Sub::HandlesVia::Toolkit::Moose::RoleTrait 0.050002 + Sub::HandlesVia::Toolkit::Mouse 0.050002 + Sub::HandlesVia::Toolkit::Mouse::PackageTrait 0.050002 + Sub::HandlesVia::Toolkit::Mouse::RoleTrait 0.050002 + Sub::HandlesVia::Toolkit::ObjectPad 0.050002 + Sub::HandlesVia::Toolkit::Plain 0.050002 + requirements: + Class::Method::Modifiers 0 + Exporter::Shiny 0 + ExtUtils::MakeMaker 6.17 + List::Util 1.54 + Role::Hooks 0.008 + Role::Tiny 0 + Type::Tiny 1.004 + perl 5.008001 + Sub-Install-0.929 + pathname: R/RJ/RJBS/Sub-Install-0.929.tar.gz + provides: + Sub::Install 0.929 + requirements: + B 0 + Carp 0 + ExtUtils::MakeMaker 6.78 + Scalar::Util 0 + perl 5.008000 + strict 0 + warnings 0 + Sub-Quote-2.006008 + pathname: H/HA/HAARG/Sub-Quote-2.006008.tar.gz + provides: + Sub::Defer 2.006008 + Sub::Quote 2.006008 + requirements: + ExtUtils::MakeMaker 0 + Scalar::Util 0 + perl 5.006 + Sub-Uplevel-0.2800 + pathname: D/DA/DAGOLDEN/Sub-Uplevel-0.2800.tar.gz + provides: + Sub::Uplevel 0.2800 + requirements: + Carp 0 + ExtUtils::MakeMaker 6.17 + constant 0 + perl 5.006 + strict 0 + warnings 0 + Symbol-Get-0.12 + pathname: F/FE/FELIPE/Symbol-Get-0.12.tar.gz + provides: + Symbol::Get 0.12 + requirements: + Call::Context 0 + ExtUtils::MakeMaker 0 + TOML-Tiny-0.20 + pathname: O/OA/OALDERS/TOML-Tiny-0.20.tar.gz + provides: + TOML::Tiny 0.20 + TOML::Tiny::Grammar 0.20 + TOML::Tiny::Parser 0.20 + TOML::Tiny::Tokenizer 0.20 + TOML::Tiny::Util 0.20 + TOML::Tiny::Writer 0.20 + requirements: + Carp 0 + Data::Dumper 0 + Encode 0 + Exporter 0 + ExtUtils::MakeMaker 0 + Math::BigInt 1.999718 + perl 5.018 + Task-Weaken-1.06 + pathname: E/ET/ETHER/Task-Weaken-1.06.tar.gz + provides: + Task::Weaken 1.06 + requirements: + Config 0 + ExtUtils::MakeMaker 0 + File::Spec 0 + Scalar::Util 1.14 + perl 5.006 + strict 0 + Term-Size-Any-0.002 + pathname: F/FE/FERREIRA/Term-Size-Any-0.002.tar.gz + provides: + Term::Size::Any 0.002 + requirements: + Devel::Hide 0 + ExtUtils::MakeMaker 0 + Module::Load::Conditional 0 + Term::Size::Perl 0 + Test::More 0 + Term-Size-Perl-0.031 + pathname: F/FE/FERREIRA/Term-Size-Perl-0.031.tar.gz + provides: + Term::Size::Perl 0.031 + requirements: + Exporter 0 + ExtUtils::CBuilder 0 + ExtUtils::MakeMaker 0 + Term-Table-0.024 + pathname: E/EX/EXODIST/Term-Table-0.024.tar.gz + provides: + Term::Table 0.024 + Term::Table::Cell 0.024 + Term::Table::CellStack 0.024 + Term::Table::HashBase 0.024 + Term::Table::LineBreak 0.024 + Term::Table::Spacer 0.024 + Term::Table::Util 0.024 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + List::Util 0 + Scalar::Util 0 + perl 5.008001 + Test-Abortable-0.003 + pathname: R/RJ/RJBS/Test-Abortable-0.003.tar.gz + provides: + Test::Abortable 0.003 + requirements: + ExtUtils::MakeMaker 6.78 + Sub::Exporter 0 + Test2::API 1.302075 + perl 5.012 + strict 0 + warnings 0 + Test-Deep-1.205 + pathname: R/RJ/RJBS/Test-Deep-1.205.tar.gz + provides: + Test::Deep 1.205 + Test::Deep::All 1.205 + Test::Deep::Any 1.205 + Test::Deep::Array 1.205 + Test::Deep::ArrayEach 1.205 + Test::Deep::ArrayElementsOnly 1.205 + Test::Deep::ArrayLength 1.205 + Test::Deep::ArrayLengthOnly 1.205 + Test::Deep::Blessed 1.205 + Test::Deep::Boolean 1.205 + Test::Deep::Cache 1.205 + Test::Deep::Cache::Simple 1.205 + Test::Deep::Class 1.205 + Test::Deep::Cmp 1.205 + Test::Deep::Code 1.205 + Test::Deep::Hash 1.205 + Test::Deep::HashEach 1.205 + Test::Deep::HashElements 1.205 + Test::Deep::HashKeys 1.205 + Test::Deep::HashKeysOnly 1.205 + Test::Deep::Ignore 1.205 + Test::Deep::Isa 1.205 + Test::Deep::ListMethods 1.205 + Test::Deep::MM 1.205 + Test::Deep::Methods 1.205 + Test::Deep::NoTest 1.205 + Test::Deep::None 1.205 + Test::Deep::Number 1.205 + Test::Deep::Obj 1.205 + Test::Deep::Ref 1.205 + Test::Deep::RefType 1.205 + Test::Deep::Regexp 1.205 + Test::Deep::RegexpMatches 1.205 + Test::Deep::RegexpOnly 1.205 + Test::Deep::RegexpRef 1.205 + Test::Deep::RegexpRefOnly 1.205 + Test::Deep::RegexpVersion 1.205 + Test::Deep::ScalarRef 1.205 + Test::Deep::ScalarRefOnly 1.205 + Test::Deep::Set 1.205 + Test::Deep::Shallow 1.205 + Test::Deep::Stack 1.205 + Test::Deep::String 1.205 + Test::Deep::SubHash 1.205 + Test::Deep::SubHashElements 1.205 + Test::Deep::SubHashKeys 1.205 + Test::Deep::SubHashKeysOnly 1.205 + Test::Deep::SuperHash 1.205 + Test::Deep::SuperHashElements 1.205 + Test::Deep::SuperHashKeys 1.205 + Test::Deep::SuperHashKeysOnly 1.205 + requirements: + ExtUtils::MakeMaker 6.78 + List::Util 1.09 + Scalar::Util 1.09 + Test::Builder 0 + Test::More 0.96 + perl 5.012 + Test-Differences-0.71 + pathname: D/DC/DCANTRELL/Test-Differences-0.71.tar.gz + provides: + Test::Differences 0.71 + requirements: + Capture::Tiny 0.24 + Data::Dumper 2.126 + ExtUtils::MakeMaker 0 + Test::More 0.88 + Text::Diff 1.43 + Test-Fatal-0.017 + pathname: R/RJ/RJBS/Test-Fatal-0.017.tar.gz + provides: + Test::Fatal 0.017 + requirements: + Carp 0 + Exporter 5.57 + ExtUtils::MakeMaker 6.78 + Test::Builder 0 + Try::Tiny 0.07 + strict 0 + warnings 0 + Test-Harness-3.50 + pathname: L/LE/LEONT/Test-Harness-3.50.tar.gz + provides: + App::Prove 3.50 + App::Prove::State 3.50 + App::Prove::State::Result 3.50 + App::Prove::State::Result::Test 3.50 + Harness::Hook undef + TAP::Base 3.50 + TAP::Formatter::Base 3.50 + TAP::Formatter::Color 3.50 + TAP::Formatter::Console 3.50 + TAP::Formatter::Console::ParallelSession 3.50 + TAP::Formatter::Console::Session 3.50 + TAP::Formatter::File 3.50 + TAP::Formatter::File::Session 3.50 + TAP::Formatter::Session 3.50 + TAP::Harness 3.50 + TAP::Harness::Env 3.50 + TAP::Object 3.50 + TAP::Parser 3.50 + TAP::Parser::Aggregator 3.50 + TAP::Parser::Grammar 3.50 + TAP::Parser::Iterator 3.50 + TAP::Parser::Iterator::Array 3.50 + TAP::Parser::Iterator::Process 3.50 + TAP::Parser::Iterator::Stream 3.50 + TAP::Parser::IteratorFactory 3.50 + TAP::Parser::Multiplexer 3.50 + TAP::Parser::Result 3.50 + TAP::Parser::Result::Bailout 3.50 + TAP::Parser::Result::Comment 3.50 + TAP::Parser::Result::Plan 3.50 + TAP::Parser::Result::Pragma 3.50 + TAP::Parser::Result::Test 3.50 + TAP::Parser::Result::Unknown 3.50 + TAP::Parser::Result::Version 3.50 + TAP::Parser::Result::YAML 3.50 + TAP::Parser::ResultFactory 3.50 + TAP::Parser::Scheduler 3.50 + TAP::Parser::Scheduler::Job 3.50 + TAP::Parser::Scheduler::Spinner 3.50 + TAP::Parser::Source 3.50 + TAP::Parser::SourceHandler 3.50 + TAP::Parser::SourceHandler::Executable 3.50 + TAP::Parser::SourceHandler::File 3.50 + TAP::Parser::SourceHandler::Handle 3.50 + TAP::Parser::SourceHandler::Perl 3.50 + TAP::Parser::SourceHandler::RawTAP 3.50 + TAP::Parser::YAMLish::Reader 3.50 + TAP::Parser::YAMLish::Writer 3.50 + Test::Harness 3.50 + requirements: + ExtUtils::MakeMaker 0 + Test-LongString-0.17 + pathname: R/RG/RGARCIA/Test-LongString-0.17.tar.gz + provides: + Test::LongString 0.17 + requirements: + ExtUtils::MakeMaker 0 + Test::Builder 0.12 + Test::Builder::Tester 1.04 + Test-MockRandom-1.01 + pathname: D/DA/DAGOLDEN/Test-MockRandom-1.01.tar.gz + provides: + Test::MockRandom 1.01 + requirements: + Carp 0 + ExtUtils::MakeMaker 6.17 + strict 0 + warnings 0 + Test-Perl-Critic-1.04 + pathname: P/PE/PETDANCE/Test-Perl-Critic-1.04.tar.gz + provides: + Test::Perl::Critic 1.04 + requirements: + Carp 0 + English 0 + MCE 1.827 + Module::Build 0.4 + Perl::Critic 1.105 + Perl::Critic::Utils 1.105 + Perl::Critic::Violation 1.105 + Test::Builder 0.88 + Test::More 0 + strict 0 + warnings 0 + Test-Requires-0.11 + pathname: T/TO/TOKUHIROM/Test-Requires-0.11.tar.gz + provides: + Test::Requires 0.11 + requirements: + ExtUtils::MakeMaker 6.64 + Test::Builder::Module 0 + Test::More 0.47 + perl 5.006 + Test-RequiresInternet-0.05 + pathname: M/MA/MALLEN/Test-RequiresInternet-0.05.tar.gz + provides: + Test::RequiresInternet 0.05 + requirements: + ExtUtils::MakeMaker 0 + Socket 0 + strict 0 + warnings 0 + Test-Routine-0.031 + pathname: R/RJ/RJBS/Test-Routine-0.031.tar.gz + provides: + Test::Routine 0.031 + Test::Routine::Common 0.031 + Test::Routine::Compositor 0.031 + Test::Routine::Manual::Demo 0.031 + Test::Routine::Runner 0.031 + Test::Routine::Test 0.031 + Test::Routine::Test::Role 0.031 + Test::Routine::Util 0.031 + requirements: + Carp 0 + Class::Load 0 + ExtUtils::MakeMaker 6.78 + Moose 0 + Moose::Exporter 0 + Moose::Meta::Class 0 + Moose::Meta::Method 0 + Moose::Role 0 + Moose::Util 0 + Moose::Util::TypeConstraints 0 + Params::Util 0 + Scalar::Util 0 + Sub::Exporter 0 + Sub::Exporter::Util 0 + Test2::API 1.302045 + Test::Abortable 0.002 + Test::More 0.96 + Try::Tiny 0 + namespace::autoclean 0 + namespace::clean 0 + perl 5.012000 + strict 0 + warnings 0 + Test-SharedFork-0.35 + pathname: E/EX/EXODIST/Test-SharedFork-0.35.tar.gz + provides: + Test::SharedFork 0.35 + Test::SharedFork::Array undef + Test::SharedFork::Scalar undef + Test::SharedFork::Store undef + requirements: + ExtUtils::MakeMaker 6.64 + File::Temp 0 + Test::Builder 0.32 + Test::Builder::Module 0 + Test::More 0.88 + perl 5.008_001 + Test-Simple-1.302210 + pathname: E/EX/EXODIST/Test-Simple-1.302210.tar.gz + provides: + Test2 1.302210 + Test2::API 1.302210 + Test2::API::Breakage 1.302210 + Test2::API::Context 1.302210 + Test2::API::Instance 1.302210 + Test2::API::InterceptResult 1.302210 + Test2::API::InterceptResult::Event 1.302210 + Test2::API::InterceptResult::Facet 1.302210 + Test2::API::InterceptResult::Hub 1.302210 + Test2::API::InterceptResult::Squasher 1.302210 + Test2::API::Stack 1.302210 + Test2::AsyncSubtest 1.302210 + Test2::AsyncSubtest::Event::Attach 1.302210 + Test2::AsyncSubtest::Event::Detach 1.302210 + Test2::AsyncSubtest::Formatter 1.302210 + Test2::AsyncSubtest::Hub 1.302210 + Test2::Bundle 1.302210 + Test2::Bundle::Extended 1.302210 + Test2::Bundle::More 1.302210 + Test2::Bundle::Simple 1.302210 + Test2::Compare 1.302210 + Test2::Compare::Array 1.302210 + Test2::Compare::Bag 1.302210 + Test2::Compare::Base 1.302210 + Test2::Compare::Bool 1.302210 + Test2::Compare::Custom 1.302210 + Test2::Compare::DeepRef 1.302210 + Test2::Compare::Delta 1.302210 + Test2::Compare::Event 1.302210 + Test2::Compare::EventMeta 1.302210 + Test2::Compare::Float 1.302210 + Test2::Compare::Hash 1.302210 + Test2::Compare::Isa 1.302210 + Test2::Compare::Meta 1.302210 + Test2::Compare::Negatable 1.302210 + Test2::Compare::Number 1.302210 + Test2::Compare::Object 1.302210 + Test2::Compare::OrderedSubset 1.302210 + Test2::Compare::Pattern 1.302210 + Test2::Compare::Ref 1.302210 + Test2::Compare::Regex 1.302210 + Test2::Compare::Scalar 1.302210 + Test2::Compare::Set 1.302210 + Test2::Compare::String 1.302210 + Test2::Compare::Undef 1.302210 + Test2::Compare::Wildcard 1.302210 + Test2::Env 1.302210 + Test2::Event 1.302210 + Test2::Event::Bail 1.302210 + Test2::Event::Diag 1.302210 + Test2::Event::Encoding 1.302210 + Test2::Event::Exception 1.302210 + Test2::Event::Fail 1.302210 + Test2::Event::Generic 1.302210 + Test2::Event::Note 1.302210 + Test2::Event::Ok 1.302210 + Test2::Event::Pass 1.302210 + Test2::Event::Plan 1.302210 + Test2::Event::Skip 1.302210 + Test2::Event::Subtest 1.302210 + Test2::Event::TAP::Version 1.302210 + Test2::Event::V2 1.302210 + Test2::Event::Waiting 1.302210 + Test2::EventFacet 1.302210 + Test2::EventFacet::About 1.302210 + Test2::EventFacet::Amnesty 1.302210 + Test2::EventFacet::Assert 1.302210 + Test2::EventFacet::Control 1.302210 + Test2::EventFacet::Error 1.302210 + Test2::EventFacet::Hub 1.302210 + Test2::EventFacet::Info 1.302210 + Test2::EventFacet::Info::Table 1.302210 + Test2::EventFacet::Meta 1.302210 + Test2::EventFacet::Parent 1.302210 + Test2::EventFacet::Plan 1.302210 + Test2::EventFacet::Render 1.302210 + Test2::EventFacet::Trace 1.302210 + Test2::Formatter 1.302210 + Test2::Formatter::TAP 1.302210 + Test2::Hub 1.302210 + Test2::Hub::Interceptor 1.302210 + Test2::Hub::Interceptor::Terminator 1.302210 + Test2::Hub::Subtest 1.302210 + Test2::IPC 1.302210 + Test2::IPC::Driver 1.302210 + Test2::IPC::Driver::Files 1.302210 + Test2::Manual 1.302210 + Test2::Manual::Anatomy 1.302210 + Test2::Manual::Anatomy::API 1.302210 + Test2::Manual::Anatomy::Context 1.302210 + Test2::Manual::Anatomy::EndToEnd 1.302210 + Test2::Manual::Anatomy::Event 1.302210 + Test2::Manual::Anatomy::Hubs 1.302210 + Test2::Manual::Anatomy::IPC 1.302210 + Test2::Manual::Anatomy::Utilities 1.302210 + Test2::Manual::Concurrency 1.302210 + Test2::Manual::Contributing 1.302210 + Test2::Manual::Testing 1.302210 + Test2::Manual::Testing::Introduction 1.302210 + Test2::Manual::Testing::Migrating 1.302210 + Test2::Manual::Testing::Planning 1.302210 + Test2::Manual::Testing::Todo 1.302210 + Test2::Manual::Tooling 1.302210 + Test2::Manual::Tooling::FirstTool 1.302210 + Test2::Manual::Tooling::Formatter 1.302210 + Test2::Manual::Tooling::Nesting 1.302210 + Test2::Manual::Tooling::Plugin::TestExit 1.302210 + Test2::Manual::Tooling::Plugin::TestingDone 1.302210 + Test2::Manual::Tooling::Plugin::ToolCompletes 1.302210 + Test2::Manual::Tooling::Plugin::ToolStarts 1.302210 + Test2::Manual::Tooling::Subtest 1.302210 + Test2::Manual::Tooling::TestBuilder 1.302210 + Test2::Manual::Tooling::Testing 1.302210 + Test2::Mock 1.302210 + Test2::Plugin 1.302210 + Test2::Plugin::BailOnFail 1.302210 + Test2::Plugin::DieOnFail 1.302210 + Test2::Plugin::ExitSummary 1.302210 + Test2::Plugin::SRand 1.302210 + Test2::Plugin::Times 1.302210 + Test2::Plugin::UTF8 1.302210 + Test2::Require 1.302210 + Test2::Require::AuthorTesting 1.302210 + Test2::Require::AutomatedTesting 1.302210 + Test2::Require::EnvVar 1.302210 + Test2::Require::ExtendedTesting 1.302210 + Test2::Require::Fork 1.302210 + Test2::Require::Module 1.302210 + Test2::Require::NonInteractiveTesting 1.302210 + Test2::Require::Perl 1.302210 + Test2::Require::RealFork 1.302210 + Test2::Require::ReleaseTesting 1.302210 + Test2::Require::Threads 1.302210 + Test2::Suite 1.302210 + Test2::Todo 1.302210 + Test2::Tools 1.302210 + Test2::Tools::AsyncSubtest 1.302210 + Test2::Tools::Basic 1.302210 + Test2::Tools::Class 1.302210 + Test2::Tools::ClassicCompare 1.302210 + Test2::Tools::Compare 1.302210 + Test2::Tools::Defer 1.302210 + Test2::Tools::Encoding 1.302210 + Test2::Tools::Event 1.302210 + Test2::Tools::Exception 1.302210 + Test2::Tools::Exports 1.302210 + Test2::Tools::GenTemp 1.302210 + Test2::Tools::Grab 1.302210 + Test2::Tools::Mock 1.302210 + Test2::Tools::Ref 1.302210 + Test2::Tools::Refcount 1.302210 + Test2::Tools::Spec 1.302210 + Test2::Tools::Subtest 1.302210 + Test2::Tools::Target 1.302210 + Test2::Tools::Tester 1.302210 + Test2::Tools::Tiny 1.302210 + Test2::Tools::Warnings 1.302210 + Test2::Util 1.302210 + Test2::Util::ExternalMeta 1.302210 + Test2::Util::Facets2Legacy 1.302210 + Test2::Util::Grabber 1.302210 + Test2::Util::Guard 1.302210 + Test2::Util::HashBase 1.302210 + Test2::Util::Importer 1.302210 + Test2::Util::Ref 1.302210 + Test2::Util::Sig 1.302210 + Test2::Util::Stash 1.302210 + Test2::Util::Sub 1.302210 + Test2::Util::Table 1.302210 + Test2::Util::Table::Cell 1.302210 + Test2::Util::Table::LineBreak 1.302210 + Test2::Util::Term 1.302210 + Test2::Util::Times 1.302210 + Test2::Util::Trace 1.302210 + Test2::V0 1.302210 + Test2::Workflow 1.302210 + Test2::Workflow::BlockBase 1.302210 + Test2::Workflow::Build 1.302210 + Test2::Workflow::Runner 1.302210 + Test2::Workflow::Task 1.302210 + Test2::Workflow::Task::Action 1.302210 + Test2::Workflow::Task::Group 1.302210 + Test::Builder 1.302210 + Test::Builder::Formatter 1.302210 + Test::Builder::Module 1.302210 + Test::Builder::Tester 1.302210 + Test::Builder::Tester::Color 1.302210 + Test::Builder::Tester::Tie 1.302210 + Test::Builder::TodoDiag 1.302210 + Test::More 1.302210 + Test::Simple 1.302210 + Test::Tester 1.302210 + Test::Tester::Capture 1.302210 + Test::Tester::CaptureRunner 1.302210 + Test::Tester::Delegate 1.302210 + Test::use::ok 1.302210 + ok 1.302210 + requirements: + B 0 + Data::Dumper 0 + Exporter 0 + ExtUtils::MakeMaker 0 + File::Spec 0 + File::Temp 0 + Scalar::Util 1.13 + Storable 0 + Term::Table 0.013 + Time::HiRes 0 + overload 0 + perl 5.006002 + utf8 0 + Test-TCP-2.22 + pathname: M/MI/MIYAGAWA/Test-TCP-2.22.tar.gz + provides: + Net::EmptyPort undef + Test::TCP 2.22 + Test::TCP::CheckPort undef + requirements: + ExtUtils::MakeMaker 6.64 + IO::Socket::INET 0 + IO::Socket::IP 0 + Test::More 0 + Test::SharedFork 0.29 + Time::HiRes 0 + perl 5.008001 + Test-Vars-0.017 + pathname: J/JK/JKEENAN/Test-Vars-0.017.tar.gz + provides: + Test::Vars 0.017 + requirements: + B 0 + ExtUtils::MakeMaker 6.17 + ExtUtils::Manifest 0 + IO::Pipe 0 + List::Util 1.33 + Storable 0 + Symbol 0 + parent 0 + perl 5.010000 + Test-Warn-0.37 + pathname: B/BI/BIGJ/Test-Warn-0.37.tar.gz + provides: + Test::Warn 0.37 + requirements: + Carp 1.22 + ExtUtils::MakeMaker 0 + Sub::Uplevel 0.12 + Test::Builder 0.13 + Test::Builder::Tester 1.02 + perl 5.006 + Text-CSV_XS-1.60 + pathname: H/HM/HMBRAND/Text-CSV_XS-1.60.tgz + provides: + Text::CSV_XS 1.60 + requirements: + Config 0 + ExtUtils::MakeMaker 0 + IO::Handle 0 + Test::More 0 + XSLoader 0 + Text-Diff-1.45 + pathname: N/NE/NEILB/Text-Diff-1.45.tar.gz + provides: + Text::Diff 1.45 + Text::Diff::Base 1.45 + Text::Diff::Config 1.44 + Text::Diff::Table 1.44 + requirements: + Algorithm::Diff 1.19 + Exporter 0 + ExtUtils::MakeMaker 0 + perl 5.006 + Text-Glob-0.11 + pathname: R/RC/RCLAMP/Text-Glob-0.11.tar.gz + provides: + Text::Glob 0.11 + requirements: + Exporter 0 + ExtUtils::MakeMaker 0 + constant 0 + perl 5.00503 + Text-SimpleTable-2.07 + pathname: M/MR/MRAMBERG/Text-SimpleTable-2.07.tar.gz + provides: + Text::SimpleTable 2.07 + requirements: + ExtUtils::MakeMaker 0 + Test::More 0 + perl 5.008001 + Text-SimpleTable-AutoWidth-0.09 + pathname: C/CU/CUB/Text-SimpleTable-AutoWidth-0.09.tar.gz + provides: + Text::SimpleTable::AutoWidth 0.09 + requirements: + ExtUtils::MakeMaker 0 + List::Util 0 + Moo 0 + Text::SimpleTable 0 + strict 0 + warnings 0 + Text-Template-1.61 + pathname: M/MS/MSCHOUT/Text-Template-1.61.tar.gz + provides: + Text::Template 1.61 + Text::Template::Preprocess 1.61 + requirements: + Carp 0 + Encode 0 + Exporter 0 + ExtUtils::MakeMaker 0 + base 0 + perl 5.008 + strict 0 + warnings 0 + Throwable-1.001 + pathname: R/RJ/RJBS/Throwable-1.001.tar.gz + provides: + StackTrace::Auto 1.001 + Throwable 1.001 + Throwable::Error 1.001 + requirements: + Carp 0 + Devel::StackTrace 1.32 + ExtUtils::MakeMaker 6.78 + Module::Runtime 0.002 + Moo 1.000001 + Moo::Role 0 + Scalar::Util 0 + Sub::Quote 0 + overload 0 + Tie-ToObject-0.03 + pathname: N/NU/NUFFIN/Tie-ToObject-0.03.tar.gz + provides: + Tie::ToObject 0.03 + requirements: + ExtUtils::MakeMaker 0 + Scalar::Util 0 + Test::More 0 + Test::use::ok 0 + Tie::RefHash 0 + Time-Duration-1.21 + pathname: N/NE/NEILB/Time-Duration-1.21.tar.gz + provides: + Time::Duration 1.21 + requirements: + Exporter 0 + ExtUtils::MakeMaker 0 + constant 0 + perl 5.006 + strict 0 + warnings 0 + Time-Duration-Parse-0.16 + pathname: N/NE/NEILB/Time-Duration-Parse-0.16.tar.gz + provides: + Time::Duration::Parse 0.16 + requirements: + Carp 0 + Exporter 5.57 + ExtUtils::MakeMaker 0 + perl 5.006 + strict 0 + warnings 0 + Time-Local-1.35 + pathname: D/DR/DROLSKY/Time-Local-1.35.tar.gz + provides: + Time::Local 1.35 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + constant 0 + parent 0 + strict 0 + TimeDate-2.33 + pathname: A/AT/ATOOMIC/TimeDate-2.33.tar.gz + provides: + Date::Format 2.24 + Date::Format::Generic 2.24 + Date::Language 1.10 + Date::Language::Afar 0.99 + Date::Language::Amharic 1.00 + Date::Language::Austrian 1.01 + Date::Language::Brazilian 1.01 + Date::Language::Bulgarian 1.01 + Date::Language::Chinese 1.00 + Date::Language::Chinese_GB 1.01 + Date::Language::Czech 1.01 + Date::Language::Danish 1.01 + Date::Language::Dutch 1.02 + Date::Language::English 1.01 + Date::Language::Finnish 1.01 + Date::Language::French 1.04 + Date::Language::Gedeo 0.99 + Date::Language::German 1.02 + Date::Language::Greek 1.00 + Date::Language::Hungarian 1.01 + Date::Language::Icelandic 1.01 + Date::Language::Italian 1.01 + Date::Language::Norwegian 1.01 + Date::Language::Occitan 1.04 + Date::Language::Oromo 0.99 + Date::Language::Romanian 1.01 + Date::Language::Russian 1.01 + Date::Language::Russian_cp1251 1.01 + Date::Language::Russian_koi8r 1.01 + Date::Language::Sidama 0.99 + Date::Language::Somali 0.99 + Date::Language::Spanish 1.00 + Date::Language::Swedish 1.01 + Date::Language::Tigrinya 1.00 + Date::Language::TigrinyaEritrean 1.00 + Date::Language::TigrinyaEthiopian 1.00 + Date::Language::Turkish 1.0 + Date::Parse 2.33 + Time::Zone 2.24 + TimeDate 1.21 + requirements: + ExtUtils::MakeMaker 0 + Tree-Simple-1.34 + pathname: R/RS/RSAVAGE/Tree-Simple-1.34.tgz + provides: + Tree::Simple 1.34 + Tree::Simple::Visitor 1.34 + requirements: + ExtUtils::MakeMaker 0 + Scalar::Util 1.18 + constant 0 + strict 0 + warnings 0 + Tree-Simple-VisitorFactory-0.16 + pathname: R/RS/RSAVAGE/Tree-Simple-VisitorFactory-0.16.tgz + provides: + Tree::Simple::Visitor::BreadthFirstTraversal 0.16 + Tree::Simple::Visitor::CreateDirectoryTree 0.16 + Tree::Simple::Visitor::FindByNodeValue 0.16 + Tree::Simple::Visitor::FindByPath 0.16 + Tree::Simple::Visitor::FindByUID 0.16 + Tree::Simple::Visitor::FromNestedArray 0.16 + Tree::Simple::Visitor::FromNestedHash 0.16 + Tree::Simple::Visitor::GetAllDescendents 0.16 + Tree::Simple::Visitor::LoadClassHierarchy 0.16 + Tree::Simple::Visitor::LoadDirectoryTree 0.16 + Tree::Simple::Visitor::PathToRoot 0.16 + Tree::Simple::Visitor::PostOrderTraversal 0.16 + Tree::Simple::Visitor::PreOrderTraversal 0.16 + Tree::Simple::Visitor::Sort 0.16 + Tree::Simple::Visitor::ToNestedArray 0.16 + Tree::Simple::Visitor::ToNestedHash 0.16 + Tree::Simple::Visitor::VariableDepthClone 0.16 + Tree::Simple::VisitorFactory 0.16 + requirements: + ExtUtils::MakeMaker 0 + File::Spec 0.6 + Scalar::Util 1.1 + Tree::Simple 1.12 + Tree::Simple::Visitor 1.22 + base 0 + strict 0 + warnings 0 + Try-Tiny-0.32 + pathname: E/ET/ETHER/Try-Tiny-0.32.tar.gz + provides: + Try::Tiny 0.32 + requirements: + Carp 0 + Exporter 5.57 + ExtUtils::MakeMaker 0 + constant 0 + perl 5.006 + strict 0 + warnings 0 + Twitter-API-1.0006 + pathname: M/MM/MMIMS/Twitter-API-1.0006.tar.gz + provides: + Twitter::API 1.0006 + Twitter::API::Context 1.0006 + Twitter::API::Error 1.0006 + Twitter::API::Role::RequestArgs 1.0006 + Twitter::API::Trait::ApiMethods 1.0006 + Twitter::API::Trait::AppAuth 1.0006 + Twitter::API::Trait::DecodeHtmlEntities 1.0006 + Twitter::API::Trait::Enchilada 1.0006 + Twitter::API::Trait::Migration 1.0006 + Twitter::API::Trait::NormalizeBooleans 1.0006 + Twitter::API::Trait::RateLimiting 1.0006 + Twitter::API::Trait::RetryOnError 1.0006 + Twitter::API::Util 1.0006 + requirements: + Carp 0 + Digest::SHA 0 + Encode 0 + HTML::Entities 0 + HTTP::Request::Common 0 + HTTP::Thin 0 + IO::Socket::SSL 0 + JSON::MaybeXS 0 + Module::Build::Tiny 0.034 + Module::Runtime 0 + Moo 0 + Moo::Role 0 + MooX::Aliases 0 + MooX::Traits 0 + Ref::Util 0 + Scalar::Util 0 + StackTrace::Auto 0 + Sub::Exporter::Progressive 0 + Throwable 0 + Time::HiRes 0 + Time::Local 0 + Try::Tiny 0 + URI 0 + URL::Encode 0 + WWW::OAuth 0.006 + namespace::clean 0 + perl v5.14.1 + Type-Tiny-2.008001 + pathname: T/TO/TOBYINK/Type-Tiny-2.008001.tar.gz + provides: + Devel::TypeTiny::Perl58Compat 2.008001 + Error::TypeTiny 2.008001 + Error::TypeTiny::Assertion 2.008001 + Error::TypeTiny::Compilation 2.008001 + Error::TypeTiny::WrongNumberOfParameters 2.008001 + Eval::TypeTiny 2.008001 + Eval::TypeTiny::CodeAccumulator 2.008001 + Reply::Plugin::TypeTiny 2.008001 + Test::TypeTiny 2.008001 + Type::Coercion 2.008001 + Type::Coercion::FromMoose 2.008001 + Type::Coercion::Union 2.008001 + Type::Library 2.008001 + Type::Params 2.008001 + Type::Params::Alternatives 2.008001 + Type::Params::Parameter 2.008001 + Type::Params::Signature 2.008001 + Type::Parser 2.008001 + Type::Parser::AstBuilder 2.008001 + Type::Parser::Token 2.008001 + Type::Parser::TokenStream 2.008001 + Type::Registry 2.008001 + Type::Tie 2.008001 + Type::Tie::ARRAY 2.008001 + Type::Tie::BASE 2.008001 + Type::Tie::HASH 2.008001 + Type::Tie::SCALAR 2.008001 + Type::Tiny 2.008001 + Type::Tiny::Bitfield 2.008001 + Type::Tiny::Class 2.008001 + Type::Tiny::ConstrainedObject 2.008001 + Type::Tiny::Duck 2.008001 + Type::Tiny::Enum 2.008001 + Type::Tiny::Intersection 2.008001 + Type::Tiny::Role 2.008001 + Type::Tiny::Union 2.008001 + Type::Utils 2.008001 + Types::Common 2.008001 + Types::Common::Numeric 2.008001 + Types::Common::String 2.008001 + Types::Standard 2.008001 + Types::Standard::ArrayRef 2.008001 + Types::Standard::CycleTuple 2.008001 + Types::Standard::Dict 2.008001 + Types::Standard::HashRef 2.008001 + Types::Standard::Map 2.008001 + Types::Standard::ScalarRef 2.008001 + Types::Standard::StrMatch 2.008001 + Types::Standard::Tied 2.008001 + Types::Standard::Tuple 2.008001 + Types::TypeTiny 2.008001 + requirements: + Exporter::Tiny 1.006000 + ExtUtils::MakeMaker 6.17 + perl 5.008001 + Types-Path-Tiny-0.006 + pathname: D/DA/DAGOLDEN/Types-Path-Tiny-0.006.tar.gz + provides: + Types::Path::Tiny 0.006 + requirements: + ExtUtils::MakeMaker 6.17 + Path::Tiny 0 + Type::Library 0.008 + Type::Utils 0 + Types::Standard 0 + Types::TypeTiny 0.004 + perl 5.008001 + strict 0 + warnings 0 + Types-Self-0.002 + pathname: T/TO/TOBYINK/Types-Self-0.002.tar.gz + provides: + Types::Self 0.002 + requirements: + ExtUtils::MakeMaker 6.17 + Role::Hooks 0 + Types::Standard 1.012 + perl 5.008001 + Types-Serialiser-1.01 + pathname: M/ML/MLEHMANN/Types-Serialiser-1.01.tar.gz + provides: + JSON::PP::Boolean 1.01 + Types::Serialiser 1.01 + Types::Serialiser::BooleanBase 1.01 + Types::Serialiser::Error 1.01 + requirements: + ExtUtils::MakeMaker 0 + common::sense 0 + Types-URI-0.007 + pathname: T/TO/TOBYINK/Types-URI-0.007.tar.gz + provides: + Types::URI 0.007 + requirements: + ExtUtils::MakeMaker 6.17 + Type::Library 1.000000 + Types::Path::Tiny 0 + Types::Standard 0 + Types::UUID 0 + URI 0 + URI::FromHash 0 + perl 5.008 + Types-UUID-0.004 + pathname: T/TO/TOBYINK/Types-UUID-0.004.tar.gz + provides: + Types::UUID 0.004 + requirements: + ExtUtils::MakeMaker 6.17 + Type::Tiny 1.000000 + UUID::Tiny 1.02 + perl 5.008 + UNIVERSAL-require-0.19 + pathname: N/NE/NEILB/UNIVERSAL-require-0.19.tar.gz + provides: + UNIVERSAL::require 0.19 + requirements: + Carp 0 + ExtUtils::MakeMaker 0 + Test::More 0.47 + perl 5.006 + strict 0 + warnings 0 + URI-5.31 + pathname: O/OA/OALDERS/URI-5.31.tar.gz + provides: + URI 5.31 + URI::Escape 5.31 + URI::Heuristic 5.31 + URI::IRI 5.31 + URI::QueryParam 5.31 + URI::Split 5.31 + URI::URL 5.31 + URI::WithBase 5.31 + URI::data 5.31 + URI::file 5.31 + URI::file::Base 5.31 + URI::file::FAT 5.31 + URI::file::Mac 5.31 + URI::file::OS2 5.31 + URI::file::QNX 5.31 + URI::file::Unix 5.31 + URI::file::Win32 5.31 + URI::ftp 5.31 + URI::ftpes 5.31 + URI::ftps 5.31 + URI::geo 5.31 + URI::gopher 5.31 + URI::http 5.31 + URI::https 5.31 + URI::icap 5.31 + URI::icaps 5.31 + URI::irc 5.31 + URI::ircs 5.31 + URI::ldap 5.31 + URI::ldapi 5.31 + URI::ldaps 5.31 + URI::mailto 5.31 + URI::mms 5.31 + URI::news 5.31 + URI::nntp 5.31 + URI::nntps 5.31 + URI::otpauth 5.31 + URI::pop 5.31 + URI::rlogin 5.31 + URI::rsync 5.31 + URI::rtsp 5.31 + URI::rtspu 5.31 + URI::scp 5.31 + URI::sftp 5.31 + URI::sip 5.31 + URI::sips 5.31 + URI::snews 5.31 + URI::ssh 5.31 + URI::telnet 5.31 + URI::tn3270 5.31 + URI::urn 5.31 + URI::urn::isbn 5.31 + URI::urn::oid 5.31 + requirements: + Carp 0 + Cwd 0 + Data::Dumper 0 + Encode 0 + Exporter 5.57 + ExtUtils::MakeMaker 0 + MIME::Base32 0 + MIME::Base64 2 + Net::Domain 0 + Scalar::Util 0 + constant 0 + integer 0 + overload 0 + parent 0 + perl 5.008001 + strict 0 + utf8 0 + warnings 0 + URI-Find-20160806 + pathname: M/MS/MSCHWERN/URI-Find-20160806.tar.gz + provides: + URI::Find 20160806 + URI::Find::Schemeless 20160806 + requirements: + Module::Build 0.30 + Test::More 0.88 + URI 1.60 + perl v5.8.8 + URI-FromHash-0.05 + pathname: D/DR/DROLSKY/URI-FromHash-0.05.tar.gz + provides: + URI::FromHash 0.05 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 0 + Params::Validate 0 + URI 1.68 + strict 0 + warnings 0 + URI-Nested-0.10 + pathname: D/DW/DWHEELER/URI-Nested-0.10.tar.gz + provides: + URI::Nested 0.10 + requirements: + Module::Build 0.30 + Test::More 0.88 + URI 1.40 + perl 5.008001 + URI-db-0.23 + pathname: D/DW/DWHEELER/URI-db-0.23.tar.gz + provides: + URI::cassandra 0.23 + URI::clickhouse 0.20 + URI::cockroach 0.23 + URI::cockroachdb 0.23 + URI::couch 0.23 + URI::couchdb 0.23 + URI::cubrid 0.23 + URI::db 0.23 + URI::db2 0.23 + URI::derby 0.23 + URI::exasol 0.23 + URI::firebird 0.23 + URI::hive 0.23 + URI::impala 0.23 + URI::informix 0.23 + URI::ingres 0.23 + URI::interbase 0.23 + URI::ldapdb 0.23 + URI::maria 0.23 + URI::mariadb 0.23 + URI::max 0.23 + URI::maxdb 0.23 + URI::monet 0.23 + URI::monetdb 0.23 + URI::mongo 0.23 + URI::mongodb 0.23 + URI::mssql 0.23 + URI::mysql 0.23 + URI::oracle 0.23 + URI::pg 0.23 + URI::pgsql 0.23 + URI::pgxc 0.23 + URI::postgres 0.23 + URI::postgresql 0.23 + URI::postgresxc 0.23 + URI::redshift 0.23 + URI::snowflake 0.23 + URI::sqlite 0.23 + URI::sqlite3 0.23 + URI::sqlserver 0.23 + URI::sybase 0.23 + URI::teradata 0.23 + URI::unify 0.23 + URI::vertica 0.23 + URI::yugabyte 0.23 + URI::yugabytedb 0.23 + requirements: + Module::Build 0.30 + Test::More 0.88 + URI 1.40 + URI::Nested 0.10 + perl 5.008001 + URI-ws-0.03 + pathname: P/PL/PLICEASE/URI-ws-0.03.tar.gz + provides: + URI::ws 0.03 + URI::wss 0.03 + requirements: + ExtUtils::MakeMaker 6.30 + URI 0 + URL-Encode-0.03 + pathname: C/CH/CHANSEN/URL-Encode-0.03.tar.gz + provides: + URL::Encode 0.03 + URL::Encode::PP 0.03 + requirements: + Carp 0 + Exporter 0 + ExtUtils::MakeMaker 6.59 + Test::More 0.88 + XSLoader 0 + perl 5.008001 + UUID-Tiny-1.04 + pathname: C/CA/CAUGUSTIN/UUID-Tiny-1.04.tar.gz + provides: + UUID::Tiny 1.04 + requirements: + Carp 0 + Digest::MD5 0 + ExtUtils::MakeMaker 0 + IO::File 0 + MIME::Base64 0 + POSIX 0 + Test::More 0 + Time::HiRes 0 + Unicode-LineBreak-2019.001 + pathname: N/NE/NEZUMI/Unicode-LineBreak-2019.001.tar.gz + provides: + Text::LineFold 2018.012 + Unicode::GCString 2013.10 + Unicode::LineBreak 2019.001 + requirements: + Encode 1.98 + ExtUtils::MakeMaker 6.26 + MIME::Charset v1.6.2 + Test::More 0.45 + perl 5.008 + Variable-Magic-0.64 + pathname: V/VP/VPIT/Variable-Magic-0.64.tar.gz + provides: + Variable::Magic 0.64 + requirements: + Carp 0 + Config 0 + Exporter 0 + ExtUtils::MakeMaker 0 + IO::Handle 0 + IO::Select 0 + IPC::Open3 0 + POSIX 0 + Socket 0 + Test::More 0 + XSLoader 0 + base 0 + lib 0 + perl 5.008 + WWW-Form-UrlEncoded-0.26 + pathname: K/KA/KAZEBURO/WWW-Form-UrlEncoded-0.26.tar.gz + provides: + WWW::Form::UrlEncoded 0.26 + WWW::Form::UrlEncoded::PP undef + requirements: + Exporter 0 + Module::Build 0.4005 + perl 5.008001 + WWW-OAuth-1.003 + pathname: D/DB/DBOOK/WWW-OAuth-1.003.tar.gz + provides: + WWW::OAuth 1.003 + WWW::OAuth::Request 1.003 + WWW::OAuth::Request::Basic 1.003 + WWW::OAuth::Request::HTTP_Request 1.003 + WWW::OAuth::Request::Mojo 1.003 + WWW::OAuth::Util 1.003 + requirements: + Carp 0 + Class::Tiny::Chained 0 + Crypt::SysRandom 0 + Digest::SHA 0 + ExtUtils::MakeMaker 0 + List::Util 1.33 + Module::Runtime 0 + Role::Tiny 2.000000 + Scalar::Util 0 + URI 1.28 + URI::Escape 3.26 + WWW::Form::UrlEncoded 0.23 + perl 5.008001 + WWW-RobotRules-6.02 + pathname: G/GA/GAAS/WWW-RobotRules-6.02.tar.gz + provides: + WWW::RobotRules 6.02 + WWW::RobotRules::AnyDBM_File 6.00 + WWW::RobotRules::InCore 6.02 + requirements: + AnyDBM_File 0 + ExtUtils::MakeMaker 0 + Fcntl 0 + URI 1.10 + perl 5.008001 + XML-Parser-2.47 + pathname: T/TO/TODDR/XML-Parser-2.47.tar.gz + provides: + XML::Parser 2.47 + XML::Parser::Expat 2.47 + XML::Parser::Style::Debug undef + XML::Parser::Style::Objects undef + XML::Parser::Style::Stream undef + XML::Parser::Style::Subs undef + XML::Parser::Style::Tree undef + requirements: + ExtUtils::MakeMaker 0 + LWP::UserAgent 0 + perl 5.00405 + XML-XPath-1.48 + pathname: M/MA/MANWAR/XML-XPath-1.48.tar.gz + provides: + XML::XPath 1.48 + XML::XPath::Boolean 1.48 + XML::XPath::Builder 1.48 + XML::XPath::Expr 1.48 + XML::XPath::Function 1.48 + XML::XPath::Literal 1.48 + XML::XPath::LocationPath 1.48 + XML::XPath::Node 1.48 + XML::XPath::Node::Attribute 1.48 + XML::XPath::Node::AttributeImpl 1.48 + XML::XPath::Node::Comment 1.48 + XML::XPath::Node::Element 1.48 + XML::XPath::Node::Namespace 1.48 + XML::XPath::Node::PI 1.48 + XML::XPath::Node::Text 1.48 + XML::XPath::NodeSet 1.48 + XML::XPath::Number 1.48 + XML::XPath::Parser 1.48 + XML::XPath::PerlSAX 1.48 + XML::XPath::Root 1.48 + XML::XPath::Step 1.48 + XML::XPath::Variable 1.48 + XML::XPath::XMLParser 1.48 + requirements: + ExtUtils::MakeMaker 0 + Path::Tiny 0.076 + Scalar::Util 1.45 + Test::More 0 + XML::Parser 2.23 + perl 5.010001 + XSLoader-0.24 + pathname: S/SA/SAPER/XSLoader-0.24.tar.gz + provides: + XSLoader 0.24 + requirements: + ExtUtils::MakeMaker 0 + Test::More 0.47 + XString-0.005 + pathname: A/AT/ATOOMIC/XString-0.005.tar.gz + provides: + XString 0.005 + requirements: + ExtUtils::MakeMaker 0 + perl 5.008 + YAML-1.31 + pathname: I/IN/INGY/YAML-1.31.tar.gz + provides: + YAML 1.31 + YAML::Any 1.31 + YAML::Dumper undef + YAML::Dumper::Base undef + YAML::Error undef + YAML::Loader undef + YAML::Loader::Base undef + YAML::Marshall undef + YAML::Mo undef + YAML::Node undef + YAML::Tag undef + YAML::Type::blessed undef + YAML::Type::code undef + YAML::Type::glob undef + YAML::Type::ref undef + YAML::Type::regexp undef + YAML::Type::undef undef + YAML::Types undef + YAML::Warning undef + yaml_mapping undef + yaml_scalar undef + yaml_sequence undef + requirements: + ExtUtils::MakeMaker 0 + perl 5.008001 + YAML-LibYAML-v0.903.0 + pathname: T/TI/TINITA/YAML-LibYAML-v0.903.0.tar.gz + provides: + YAML::LibYAML v0.903.0 + YAML::XS v0.903.0 + requirements: + B::Deparse 0 + Exporter 0 + ExtUtils::MakeMaker 0 + Scalar::Util 0 + base 0 + constant 0 + perl 5.008001 + strict 0 + warnings 0 + YAML-PP-v0.39.0 + pathname: T/TI/TINITA/YAML-PP-v0.39.0.tar.gz + provides: + YAML::PP v0.39.0 + YAML::PP::Common v0.39.0 + YAML::PP::Constructor v0.39.0 + YAML::PP::Dumper v0.39.0 + YAML::PP::Emitter v0.39.0 + YAML::PP::Exception v0.39.0 + YAML::PP::Grammar v0.39.0 + YAML::PP::Highlight v0.39.0 + YAML::PP::Lexer v0.39.0 + YAML::PP::Loader v0.39.0 + YAML::PP::Parser v0.39.0 + YAML::PP::Perl v0.39.0 + YAML::PP::Preserve::Array v0.39.0 + YAML::PP::Preserve::Hash v0.39.0 + YAML::PP::Preserve::Scalar v0.39.0 + YAML::PP::Reader v0.39.0 + YAML::PP::Reader::File v0.39.0 + YAML::PP::Render v0.39.0 + YAML::PP::Representer v0.39.0 + YAML::PP::Schema v0.39.0 + YAML::PP::Schema::Binary v0.39.0 + YAML::PP::Schema::Catchall v0.39.0 + YAML::PP::Schema::Core v0.39.0 + YAML::PP::Schema::Failsafe v0.39.0 + YAML::PP::Schema::Include v0.39.0 + YAML::PP::Schema::JSON v0.39.0 + YAML::PP::Schema::Merge v0.39.0 + YAML::PP::Schema::Perl v0.39.0 + YAML::PP::Schema::Tie::IxHash v0.39.0 + YAML::PP::Schema::YAML1_1 v0.39.0 + YAML::PP::Type::MergeKey v0.39.0 + YAML::PP::Writer v0.39.0 + YAML::PP::Writer::File v0.39.0 + requirements: + B 0 + B::Deparse 0 + Carp 0 + Data::Dumper 0 + Encode 0 + Exporter 0 + ExtUtils::MakeMaker 0 + File::Basename 0 + Getopt::Long 0 + MIME::Base64 0 + Module::Load 0 + Scalar::Util 1.07 + Tie::Array 0 + Tie::Hash 0 + base 0 + constant 0 + overload 0 + perl 5.008000 + strict 0 + warnings 0 + bareword-filehandles-0.007 + pathname: I/IL/ILMARI/bareword-filehandles-0.007.tar.gz + provides: + bareword::filehandles 0.007 + requirements: + B::Hooks::OP::Check 0 + ExtUtils::Depends 0 + ExtUtils::MakeMaker 0 + Test::More 0.88 + XSLoader 0 + if 0 + perl 5.008001 + strict 0 + warnings 0 + common-sense-3.75 + pathname: M/ML/MLEHMANN/common-sense-3.75.tar.gz + provides: + common::sense 3.75 + requirements: + ExtUtils::MakeMaker 0 + indirect-0.39 + pathname: V/VP/VPIT/indirect-0.39.tar.gz + provides: + indirect 0.39 + requirements: + Carp 0 + Config 0 + ExtUtils::MakeMaker 0 + File::Spec 0 + IO::Handle 0 + IO::Select 0 + IPC::Open3 0 + POSIX 0 + Socket 0 + Test::More 0 + XSLoader 0 + lib 0 + perl 5.008001 + libnet-3.15 + pathname: S/SH/SHAY/libnet-3.15.tar.gz + provides: + Net undef + Net::Cmd 3.15 + Net::Config 3.15 + Net::Domain 3.15 + Net::FTP 3.15 + Net::FTP::A 3.15 + Net::FTP::E 3.15 + Net::FTP::I 3.15 + Net::FTP::L 3.15 + Net::FTP::dataconn 3.15 + Net::NNTP 3.15 + Net::NNTP::_SSL 3.15 + Net::Netrc 3.15 + Net::POP3 3.15 + Net::POP3::_SSL 3.15 + Net::SMTP 3.15 + Net::SMTP::_SSL 3.15 + Net::Time 3.15 + requirements: + Carp 0 + Errno 0 + Exporter 0 + ExtUtils::MakeMaker 6.64 + Fcntl 0 + File::Basename 0 + FileHandle 0 + Getopt::Std 0 + IO::File 0 + IO::Select 0 + IO::Socket 1.05 + POSIX 0 + Socket 2.016 + Symbol 0 + Time::Local 0 + constant 0 + perl 5.008001 + strict 0 + utf8 0 + vars 0 + warnings 0 + libwww-perl-6.78 + pathname: O/OA/OALDERS/libwww-perl-6.78.tar.gz + provides: + LWP 6.78 + LWP::Authen::Basic 6.78 + LWP::Authen::Digest 6.78 + LWP::Authen::Ntlm 6.78 + LWP::ConnCache 6.78 + LWP::Debug 6.78 + LWP::Debug::TraceHTTP 6.78 + LWP::DebugFile 6.78 + LWP::MemberMixin 6.78 + LWP::Protocol 6.78 + LWP::Protocol::cpan 6.78 + LWP::Protocol::data 6.78 + LWP::Protocol::file 6.78 + LWP::Protocol::ftp 6.78 + LWP::Protocol::gopher 6.78 + LWP::Protocol::http 6.78 + LWP::Protocol::loopback 6.78 + LWP::Protocol::mailto 6.78 + LWP::Protocol::nntp 6.78 + LWP::Protocol::nogo 6.78 + LWP::RobotUA 6.78 + LWP::Simple 6.78 + LWP::UserAgent 6.78 + requirements: + Digest::MD5 0 + Encode 2.12 + Encode::Locale 0 + ExtUtils::MakeMaker 0 + File::Copy 0 + File::Listing 6 + File::Temp 0 + Getopt::Long 0 + HTML::Entities 0 + HTML::HeadParser 3.71 + HTTP::Cookies 6 + HTTP::Date 6 + HTTP::Negotiate 6 + HTTP::Request 6.18 + HTTP::Request::Common 6.18 + HTTP::Response 6.18 + HTTP::Status 6.18 + IO::Select 0 + IO::Socket 0 + LWP::MediaTypes 6 + MIME::Base64 2.1 + Module::Load 0 + Net::FTP 2.58 + Net::HTTP 6.18 + Scalar::Util 0 + Try::Tiny 0 + URI 1.10 + URI::Escape 0 + WWW::RobotRules 6 + parent 0.217 + perl 5.008001 + strict 0 + warnings 0 + multidimensional-0.014 + pathname: I/IL/ILMARI/multidimensional-0.014.tar.gz + provides: + multidimensional 0.014 + requirements: + B::Hooks::OP::Check 0.19 + CPAN::Meta 2.112580 + ExtUtils::Depends 0 + ExtUtils::MakeMaker 0 + Test::More 0.88 + XSLoader 0 + if 0 + perl 5.008001 + strict 0 + warnings 0 + namespace-autoclean-0.31 + pathname: E/ET/ETHER/namespace-autoclean-0.31.tar.gz + provides: + namespace::autoclean 0.31 + requirements: + B 0 + B::Hooks::EndOfScope 0.12 + ExtUtils::MakeMaker 0 + List::Util 0 + namespace::clean 0.20 + perl 5.006 + strict 0 + warnings 0 + namespace-clean-0.27 + pathname: R/RI/RIBASUSHI/namespace-clean-0.27.tar.gz + provides: + namespace::clean 0.27 + requirements: + B::Hooks::EndOfScope 0.12 + ExtUtils::MakeMaker 0 + Package::Stash 0.23 + perl 5.008001 + podlators-v6.0.2 + pathname: R/RR/RRA/podlators-v6.0.2.tar.gz + provides: + Pod undef + Pod::Man v6.0.2 + Pod::ParseLink v6.0.2 + Pod::Text v6.0.2 + Pod::Text::Color v6.0.2 + Pod::Text::Overstrike v6.0.2 + Pod::Text::Termcap v6.0.2 + requirements: + ExtUtils::MakeMaker 0 + Pod::Simple 3.26 + perl 5.012 + strictures-2.000006 + pathname: H/HA/HAARG/strictures-2.000006.tar.gz + provides: + strictures 2.000006 + strictures::extra undef + requirements: + bareword::filehandles 0 + indirect 0 + multidimensional 0 + perl 5.006 + version-0.9933 + pathname: L/LE/LEONT/version-0.9933.tar.gz + provides: + version 0.9933 + version::regex 0.9933 + version::vpp 0.9933 + version::vxs 0.9933 + requirements: + ExtUtils::MakeMaker 0 + perl 5.006002 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..99066703d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,64 @@ +services: + api-server: + build: + context: . + target: develop + volumes: + - './:/app/' + - '/app/local' + ports: + - '8001:8000' + environment: + # default is 120, shorten to work with compose label + COLUMNS: 96 + develop: + watch: + - path: ./cpanfile + action: rebuild + + api-test: + profiles: + - test + depends_on: + elasticsearch-test: + condition: service_healthy + build: + context: . + target: test + environment: + NET_ASYNC_HTTP_MAXCONNS: 1 + COLUMNS: 80 + ES: http://elasticsearch-test:9200 + HARNESS_ACTIVE: 1 + # Instantiate Catalyst models using metacpan_server_testing.conf + METACPAN_SERVER_CONFIG_LOCAL_SUFFIX: testing + MINICPAN: /CPAN + DEVEL_COVER_OPTIONS: +ignore,^t/|^test-data/|^etc/|^local/ + networks: + - elasticsearch + volumes: + - type: volume + source: elasticsearch-test + target: /usr/share/elasticsearch/data + + elasticsearch-test: + profiles: + - test + platform: linux/amd64 + image: elasticsearch:2.4 + environment: + - discovery.type=single-node + healthcheck: + timeout: 5s + start_period: 60s + test: ["CMD", "curl", "--fail", "http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s"] + ports: + - "9200" + networks: + - elasticsearch + +networks: + elasticsearch: + +volumes: + elasticsearch-test: diff --git a/docs/API-docs.md b/docs/API-docs.md new file mode 100644 index 000000000..10137aa6d --- /dev/null +++ b/docs/API-docs.md @@ -0,0 +1,365 @@ +# API Docs: v1 + +For an introduction to the MetaCPAN API (Application Program Interface) which requires no previous knowledge of MetaCPAN or ElasticSearch, see [the slides for "Abusing MetaCPAN for Fun and Profit"](https://www.slideshare.net/oalders/abusing-metacpan2013) or [watch the actual talk](https://www.youtube.com/watch?v=J8ymBuFlHQg). This API lets you programmatically access MetaCPAN from your own applications. + +There is also [a repository of examples](https://github.com/metacpan/metacpan-examples) you can play with to get up and running in a hurry. Rather than editing this wiki page, please send pull requests for the metacpan-examples repository. If you'd rather edit the wiki, please do, but sending the code pull requests is probably the most helpful way to approach this. + +_All of these URLs can be tested using the [MetaCPAN Explorer](https://explorer.metacpan.org)_ + +To learn more about the ElasticSearch query DSL (Domain-Specific Language) check out Clinton Gormley's [Terms of Endearment - ES Query DSL Explained](https://www.slideshare.net/clintongormley/terms-of-endearment-the-elasticsearch-query-dsl-explained) slides. + +The query syntax is explained on ElasticSearch's [reference page](https://www.elastic.co/guide/en/elasticsearch/reference/2.4/query-dsl.html). You can also check out this getting started tutorial about Elasticsearch [reference page](http://joelabrahamsson.com/elasticsearch-101/). + +## Being polite + +Currently, the only rules around using the API are to "be polite". We have enforced an upper limit of a size of 5,000 on search requests. If you need to fetch more than 5,000 items, you should look at using the scrolling API. Search this page for "scroll" to get an example using [Search::Elasticsearch](https://metacpan.org/pod/Search::Elasticsearch) or see the [Elasticsearch scroll docs](https://www.elastic.co/guide/en/elasticsearch/reference/2.4/search-request-scroll.html) if you are connecting in some other way. + +You can certainly scroll if you are fetching less than 5,000 items. You might want to do this if you are expecting a large data set, but will still need to run many requests to get all of the required data. + +Be aware that when you scroll, your docs will come back unsorted, as noted in the [ElasticSearch scan documentation](https://www.elastic.co/guide/en/elasticsearch/reference/2.4/search-request-search-type.html#scan). + +## Identifying Yourself + +Part of being polite is letting us know who you are and how to reach you. This is not mandatory, but please do consider adding your app to the [API-Consumers](https://github.com/metacpan/metacpan-api/wiki/fastapi-Consumers) page. + +## Available fields + +Available fields can be found by accessing the corresponding `_mapping` endpoint. + + +* [`/author/_mapping`](https://fastapi.metacpan.org/v1/author/_mapping) - [explore](https://explorer.metacpan.org/?url=/author/_mapping) +* [`/distribution/_mapping`](https://fastapi.metacpan.org/v1/distribution/_mapping) - [explore](https://explorer.metacpan.org/?url=/distribution/_mapping) +* [`/favorite/_mapping`](https://fastapi.metacpan.org/v1/favorite/_mapping) - [explore](https://explorer.metacpan.org/?url=/favorite/_mapping) +* [`/file/_mapping`](https://fastapi.metacpan.org/v1/file/_mapping) - [explore](https://explorer.metacpan.org/?url=/file/_mapping) +* [`/module/_mapping`](https://fastapi.metacpan.org/v1/module/_mapping) - [explore](https://explorer.metacpan.org/?url=/module/_mapping) +* [`/release/_mapping`](https://fastapi.metacpan.org/v1/release/_mapping) - [explore](https://explorer.metacpan.org/?url=/release/_mapping) + + +## Field documentation + +Fields are documented in the API codebase: https://github.com/metacpan/metacpan-api/tree/master/lib/MetaCPAN/Document Check the Pod for discussion of what the various fields represent. Be sure to have a look at https://github.com/metacpan/metacpan-api/blob/master/lib/MetaCPAN/Document/File.pm in particular as results for /module are really a thin wrapper around the `file` type. + +## Search without constraints + +Performing a search without any constraints is an easy way to get sample data + +* [`/author/_search`](https://fastapi.metacpan.org/v1/author/_search) +* [`/distribution/_search`](https://fastapi.metacpan.org/v1/distribution/_search) +* [`/favorite/_search`](https://fastapi.metacpan.org/v1/favorite/_search) +* [`/file/_search`](https://fastapi.metacpan.org/v1/file/_search) +* [`/release/_search`](https://fastapi.metacpan.org/v1/release/_search) + +## JSONP + +Simply add a `callback` query parameter with the name of your callback, and you'll get a JSONP response. + +* [/favorite?q=distribution:Moose&callback=cb](https://fastapi.metacpan.org/favorite?q=distribution:Moose&callback=cb) + +## GET convenience URLs + +You should be able to run most POST queries, but very few GET urls are currently exposed. However, these convenience endpoints can get you started. You should note that they behave differently than the POST queries in that they will return to you the latest version of a module or dist and they remove a lot of the verbose ElasticSearch data which wraps results. + +### `/distribution/{distribution}` + +The `/distribution` endpoint accepts the name of a `distribution` (e.g. [/distribution/Moose](https://fastapi.metacpan.org/v1/distribution/Moose)), which returns information about the distribution which is not specific to a version (like RT bug counts). + +### `/download_url/{module}` + +The `/download_url` endpoint exists specifically for the `cpanm` client. It takes a module name with an optional version (or range of versions) and an optional `dev` flag (for development releases) and returns a `download_url` as well as some other helpful info. + +Obviously anyone can use this endpoint, but we'll only consider changes to this endpoint after considering how `cpanm` might be affected. + +* [`https://fastapi.metacpan.org/v1/download_url/HTTP::Tiny`](https://fastapi.metacpan.org/v1/download_url/HTTP::Tiny) +* [`https://fastapi.metacpan.org/v1/download_url/Moose?version===0.01`](https://fastapi.metacpan.org/v1/download_url/Moose?version===0.01) +* [`https://fastapi.metacpan.org/v1/download_url/Moose?version=!=0.01`](https://fastapi.metacpan.org/v1/download_url/Moose?version=!=0.01) +* [`https://fastapi.metacpan.org/v1/download_url/Moose?version=<=0.02`](https://fastapi.metacpan.org/v1/download_url/Moose?version=<=0.02) +* [`https://fastapi.metacpan.org/v1/download_url/Try::Tiny?version=>0.21,<0.27,!=0.24`](https://fastapi.metacpan.org/v1/download_url/Try::Tiny?version=>0.21,<0.27,!=0.24) +* [`https://fastapi.metacpan.org/v1/download_url/Try::Tiny?version=>0.21,<0.27&dev=1`](https://fastapi.metacpan.org/v1/download_url/Try::Tiny?version=>0.21,<0.27&dev=1) +* [`https://fastapi.metacpan.org/v1/download_url/Try::Tiny?version=>0.21,<0.27,!=0.26&dev=1`](https://fastapi.metacpan.org/v1/download_url/Try::Tiny?version=>0.21,<0.27,!=0.26&dev=1) + +### `/release/{distribution}` + +### `/release/{author}/{release}` + +The `/release` endpoint accepts either the name of a `distribution` (e.g. [`/release/Moose`](https://fastapi.metacpan.org/v1/release/Moose)), which returns the most recent release of the distribution. Or provide the full path which consists of its `author` and the name of the `release` (e.g. [`/release/DOY/Moose-2.0001`](https://fastapi.metacpan.org/v1/release/DOY/Moose-2.0001)). + +### `/author/{author}` + +`author` refers to the pauseid of the author. It must be uppercased (e.g. [`/author/DOY`](https://fastapi.metacpan.org/v1/author/DOY)). + +### `/module/{module}` + +Returns the corresponding `file` of the latest version of the `module`. Considering that Moose-2.0001 is the latest release, the result of [`/module/Moose`](https://fastapi.metacpan.org/v1/module/Moose) is the same as [`/file/DOY/Moose-2.0001/lib/Moose.pm`](https://fastapi.metacpan.org/v1/file/DOY/Moose-2.0001/lib/Moose.pm). + +### `/pod/{module}` + +### `/pod/{author}/{release}/{path}` + +Returns the POD of the given module. You can change the output format by either passing a `content-type` query parameter (e.g. [`/pod/Moose?content-type=text/plain`](https://fastapi.metacpan.org/v1/pod/Moose?content-type=text/plain) or by adding an `Accept` header to the HTTP request. Valid content types are: + +* text/html (default) +* text/plain +* text/x-pod +* text/x-markdown + +### `/source/{module}` + +Returns the full source of the latest, authorized version of the given +`module`. + +## GET Searches + +Names of latest releases by OALDERS: + +[`https://fastapi.metacpan.org/v1/release/_search?q=author:OALDERS%20AND%20status:latest&fields=name,status&size=100`](https://fastapi.metacpan.org/v1/release/_search?q=author:OALDERS%20AND%20status:latest&fields=name,status&size=100) + +5,000 CPAN Authors: + +[`https://fastapi.metacpan.org/v1/author/_search?q=*&size=5000`](https://fastapi.metacpan.org/author/_search?q=*) + +All CPAN Authors Who Have Provided Twitter IDs: + +https://fastapi.metacpan.org/v1/author/_search?q=profile.name:twitter + +All CPAN Authors Who Have Updated MetaCPAN Profiles: + +https://fastapi.metacpan.org/v1/author/_search?q=updated:*&sort=updated:desc + +First 100 distributions which SZABGAB has given a ++: + +https://fastapi.metacpan.org/v1/favorite/_search?q=user:sWuxlxYeQBKoCQe1f-FQ_Q&size=100&fields=distribution + +The 100 most recent releases ( similar to https://metacpan.org/recent ) + +https://fastapi.metacpan.org/v1/release/_search?q=status:latest&fields=name,status,date&sort=date:desc&size=100 + +Number of ++'es that DOY's dists have received: + +https://fastapi.metacpan.org/v1/favorite/_search?q=author:DOY&size=0 + +List of users who have ++'ed DOY's dists and the dists they have ++'ed: + +https://fastapi.metacpan.org/v1/favorite/_search?q=author:DOY&fields=user,distribution + +Last 50 dists to get a ++: + +https://fastapi.metacpan.org/v1/favorite/_search?size=50&fields=author,user,release,date&sort=date:desc + +The Changes file of the Test-Simple distribution: + +https://fastapi.metacpan.org/v1/changes/Test-Simple + +## Querying the API with MetaCPAN::Client + +Perhaps the easiest way to get started using MetaCPAN is with [MetaCPAN::Client](https://metacpan.org/pod/MetaCPAN::Client). + +You can get started with [this example script to fetch author data](https://github.com/metacpan/metacpan-examples/blob/master/scripts/author/1-fetch-single-author.pl). + +## Querying the API with Search::Elasticsearch + +The API server at fastapi.metacpan.org is a wrapper around an [Elasticsearch](https://elasticsearch.org) instance. It adds support for the convenient GET URLs, handles authentication and does some access control. Therefore you can use the powerful API of [Search::Elasticsearch](https://metacpan.org/pod/Search::Elasticsearch) to query MetaCPAN. + +**NOTE**: The `cxn_pool => 'Static::NoPing'` is important because of the HTTP proxy we have in front of Elasticsearch. + +You can get started with [this example script to fetch author data](https://github.com/metacpan/metacpan-examples/blob/master/scripts/author/1-fetch-single-author-es.pl). + +## POST Searches + +Please feel free to add queries here as you use them in your own work, so that others can learn from you. + +### Downstream Dependencies + +This query returns a list of all releases which list MooseX::NonMoose as a +dependency. + +```sh +curl -XPOST https://fastapi.metacpan.org/v1/release/_search -d '{ + "size" : 5000, + "fields" : [ "distribution" ], + "query" : { + "bool" : { + "must" : [ + { "term" : { "dependency.module" : "MooseX::NonMoose" } }, + { "term" : { "maturity" : "released" } }, + { "term" : { "status" : "latest" } } + ] + } + } +}' +``` + +_Note it is also possible to use these queries in GET requests (useful for cross-domain JSONP requests) by appropriately encoding the JSON query into the `source` parameter of the URL. For example the query above [would become](https://fastapi.metacpan.org/v1/release/_search?source=%7B%0A%20%20%20%20%22size%22%20%3A%205000%2C%0A%20%20%20%20%22fields%22%20%3A%20%5B%20%22distribution%22%20%5D%2C%0A%20%20%20%20%22query%22%20%3A%20%7B%0A%20%20%20%20%20%20%20%20%22bool%22%20%3A%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22must%22%20%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%20%22term%22%20%3A%20%7B%20%22dependency.module%22%20%3A%20%22MooseX%3A%3ANonMoose%22%20%7D%20%7D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%20%22term%22%20%3A%20%7B%20%22maturity%22%20%3A%20%22released%22%20%7D%20%7D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%20%22term%22%20%3A%20%7B%20%22status%22%20%3A%20%22latest%22%20%7D%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D):_ + +``` +curl 'https://fastapi.metacpan.org/v1/release/_search?source=%7B%0A%20%20%20%20%22size%22%20%3A%205000%2C%0A%20%20%20%20%22fields%22%20%3A%20%5B%20%22distribution%22%20%5D%2C%0A%20%20%20%20%22query%22%20%3A%20%7B%0A%20%20%20%20%20%20%20%20%22bool%22%20%3A%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%22must%22%20%3A%20%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%20%22term%22%20%3A%20%7B%20%22dependency.module%22%20%3A%20%22MooseX%3A%3ANonMoose%22%20%7D%20%7D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%20%22term%22%20%3A%20%7B%20%22maturity%22%20%3A%20%22released%22%20%7D%20%7D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%20%22term%22%20%3A%20%7B%20%22status%22%20%3A%20%22latest%22%20%7D%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%5D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D' +``` + +### [The size of the CPAN unpacked](https://github.com/metacpan/metacpan-examples/blob/master/scripts/file/5-size-of-cpan.pl) + + +### Get license types of all releases in an arbitrary time span: + +```sh +curl -XPOST https://fastapi.metacpan.org/v1/release/_search?size=100 -d '{ + "query" : { + "range" : { + "date" : { + "gte" : "2010-06-05T00:00:00", + "lte" : "2011-06-05T00:00:00" + } + } + }, + "fields": [ "license", "name", "distribution", "date", "version_numified" ] +}' +``` + +### Aggregate by license: + +```sh +curl -XPOST https://fastapi.metacpan.org/v1/release/_search -d '{ + "query" : { + "match_all" : {} + }, + "aggs" : { + "license" : { + "terms" : { + "field" : "license" + } + } + }, + "size" : 0 +}' +``` + +### Most used file names in the root directory of releases: + +```sh +curl -XPOST https://fastapi.metacpan.org/v1/file/_search -d '{ + "query" : { + "term" : { "level" : 0 } + }, + "aggs" : { + "license" : { + "terms" : { + "size" : 100, + "field" : "name" + } + } + }, + "size" : 0 +}' +``` + +### Find all releases that contain a particular version of a module: + +```sh +curl -XPOST https://fastapi.metacpan.org/v1/file/_search -d '{ + "query" : { + "bool" : { + "must" : [ + { "term" : { "module.name" : "DBI::Profile" } }, + { "term" : { "module.version" : "2.014123" } } + ] + } + }, + "fields" : [ "release" ] +}' +``` + +### [Find all authors with Twitter in their profiles](https://github.com/metacpan/metacpan-examples/blob/master/scripts/author/1c-scroll-all-authors-with-twitter-es.pl) + +### [Get a leaderboard of ++'ed distributions](https://github.com/metacpan/metacpan-examples/blob/master/scripts/favorite/3-leaderboard-es.pl) + +### [Get a leaderboard of Authors with Most Uploads](https://github.com/metacpan/metacpan-examples/blob/master/scripts/release/2-author-upload-leaderboard-es.pl) + + +### [Search for a release by name](https://github.com/metacpan/metacpan-examples/blob/master/scripts/release/1-pkg2url-es.pl) + + +### Get the latest version numbers of your favorite modules + +Note that "size" should be the number of distributions you are looking for. + +```sh +lynx --dump --post_data https://fastapi.metacpan.org/v1/release/_search <set_indexed` **must** be called as early as possible, + otherwise things which inspect file.indexed or module.indexed will get + default values for those fields not _real_ values. + +* Similarly, `Document::File->set_authorized` should be called as soon after + `set_indexed` as possible. + + + +## Use cases to support + +Installing a package, various ways: + + cpanm Moose + cpanm Moose@2.1806 + cpanm Moose~'>2, <3' + cpanm --dev Moose + cpanm --dev Moose~'>2, <3' # maybe not? + +May need to inspect: + + release.authorized + release.status + file.indexed + file.authorized + file.status + module.indexed + module.authorized diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 000000000..da725fbf5 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,16 @@ +# Logging + +Logging is done via Log::Contextual. There are three logger configs. These +can be found in the etc folder in this repository. + +## etc/metacpan.pl + +This is the default logger config + +## etc/metacpan_interactive.pl + +This logger config is used when scripts are run at the command line + +## etc/metacpan_testing.pl + +This logger config is used by the test suite. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 000000000..5c6c2ce42 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,25 @@ +# Testing + +## Releases + +When debugging the release indexing, try setting the bulk_size param to a low number, in order to make debugging easier. + + my $server = MetaCPAN::TestServer->new( ... ); + $server->index_releases( bulk_size => 1 ); + +You can enable Elasticsearch tracing when running tests at the command line: + + ES_TRACE=1 ./bin/prove t/darkpan.t + +You'll then find extensive logging information in `es.log`, at the top level of your Git checkout. + +## Indexing a Single Release + +If you want to speed up your debugging, you can index a solitary release using +the `MC_RELEASE` environment variable. + + MC_RELEASE=var/t/tmp/fakecpan/authors/id/L/LO/LOCAL/P-1.0.20.tar.gz ./bin/prove t/00_setup.t + +Or combine this with a test specific to the release. + + MC_RELEASE=var/t/tmp/fakecpan/authors/id/L/LO/LOCAL/P-1.0.20.tar.gz ./bin/prove t/00_setup.t t/release/p-1.0.20.t diff --git a/elasticsearch/cpanratings.pl b/elasticsearch/cpanratings.pl deleted file mode 100644 index f78af9b79..000000000 --- a/elasticsearch/cpanratings.pl +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/perl -#=============================================================================== -# -# FILE: cpanratings.pl -# -# USAGE: ./cpanratings.pl -# -# DESCRIPTION: Screen-scrapper for cpanratings.perl.org's ratings and reviews -# -# OPTIONS: --- -# REQUIREMENTS: --- -# BUGS: --- -# NOTES: usage - perl cpanratings.pl Data::Dumper -# AUTHOR: J. Bobby Lopez (blopez), blopez@vmware.com, bobby.lopez@gmail.com -# COMPANY: VMware Inc. -# VERSION: 1.0 -# CREATED: 11/11/10 05:01:10 PM -# REVISION: --- -#=============================================================================== - -use strict; -use warnings; -use Data::Dumper; -use Data::Dump; - -use List::Util qw(sum); -use WWW::Mechanize::Cached; -use HTML::TokeParser::Simple; -use JSON::XS; - - -# Incoming arg = module name (e.g., Data::Dumper) -# would pull info from http://cpanratings.perl.org/dist/Data-Dumper -my $module = shift or die "Need a CPAN module as a script argument!\n"; - $module =~ s/\:\:/-/g; - -my $base_url = "http://cpanratings.perl.org/dist/"; -my $url = "http://cpanratings.perl.org/dist/". $module; -my $cacher = WWW::Mechanize::Cached->new; -my $response = $cacher->get( $url ); -my $content = $response->content; - -my @avg_rating; -my %json_hash; - - -prep_for_web(); -if ( $content !~ "

404 - File not found

" ) -{ - #dump_full_html(); # For testing - cleans up the HTML a bit before output - populate_json_hash(); - dump_json(\%json_hash); - - #print Dumper(\%ENV); -} -else -{ - print "404 Error\n"; -} - - - - -#DONE - -#____________________________________________SUBROUTINES______ -sub mean { - return sum(@_)/@_; -} - -sub dump_full_html -{ - my $p = HTML::TokeParser::Simple->new(\$content); - print "---- whole document ----\n"; - while ( my $token = $p->get_token ) - { - print $token->as_is; - } - print "\n\n"; -} - -sub dump_json -{ - my $hash_data = shift; - my $coder = JSON::XS->new->ascii->pretty->allow_nonref; - my $json = $coder->utf8->encode ($hash_data); - #binmode(STDOUT, ":utf8"); - print STDOUT $json; -} - -sub prep_for_web -{ - if ( defined($ENV{'GATEWAY_INTERFACE'}) ) - { - print "Content-type: text/html\n\n"; - } -} - - -sub populate_json_hash -{ - my $p = HTML::TokeParser::Simple->new(\$content); - my $i = 0; - while (my $token = $p->get_tag("h3")) - { - $token = $p->get_tag("a"); # start tag - $token = $p->get_token; # Module name inside - $token = $p->get_token; # end tag - $token = $p->get_token; # module version - my $module_version = $token->[1]; - $module_version =~ s/\n//g; - $module_version =~ s/.*\((.*)\).*/$1/; - - $token = $p->get_tag("img"); - my $rating = $token->[1]{'alt'} || "-"; - push @avg_rating, length($rating); - - $token = $p->get_tag("blockquote"); - my $review = $p->get_trimmed_text("/blockquote"); - - $token = $p->get_tag("a"); - my $reviewer = $p->get_trimmed_text("/a"); - my $date = $p->get_trimmed_text("br"); - chomp($date); - $date =~ s/(\d+-\d+-\d+)[[:space:]]+(\d+:\d+:\d+)/$1T$2/g; - $date =~ s/(?:^-|[[:space:]]+)//g; - - $json_hash{'reviews'}{$i}{'rating'} = length($rating); - $json_hash{'reviews'}{$i}{'review'} = $review; - $json_hash{'reviews'}{$i}{'reviewer'} = $reviewer; - $json_hash{'reviews'}{$i}{'review_date'} = $date; - $json_hash{'reviews'}{$i}{'module_version'} = $module_version; - - $i++; - } - - - if ( defined($json_hash{'reviews'}) ) - { - $json_hash{'avg_rating'} = sprintf( "%.2f", mean(@avg_rating) ); - } -} diff --git a/elasticsearch/create_index.pl b/elasticsearch/create_index.pl deleted file mode 100755 index b6e139418..000000000 --- a/elasticsearch/create_index.pl +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env perl - -use Modern::Perl; -use Find::Lib '../lib'; -use MetaCPAN; - -die "Usage: perl create_index.pl index_name" if !@ARGV; - -MetaCPAN->new->es->create_index( - index => shift @ARGV, -); diff --git a/elasticsearch/delete_index.pl b/elasticsearch/delete_index.pl deleted file mode 100755 index 632c67c66..000000000 --- a/elasticsearch/delete_index.pl +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env perl - -use Modern::Perl; -use Find::Lib '../lib'; -use MetaCPAN; - -die "Usage: perl delete_index.pl index_name" if !@ARGV; - -MetaCPAN->new->es->delete_index( - index => shift @ARGV, -); diff --git a/elasticsearch/index_authors.pl b/elasticsearch/index_authors.pl deleted file mode 100755 index 1d1aa2d7c..000000000 --- a/elasticsearch/index_authors.pl +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env perl - -=head1 SYNOPSIS - -Loads author info into db. - - perl index_authors.pl - -=cut - -use Modern::Perl; -use Data::Dump qw( dump ); -use Find::Lib '../lib'; -use MetaCPAN::Author; - -my $author = MetaCPAN::Author->new; - -my $result = $author->index_authors; -#say dump( $result ); diff --git a/elasticsearch/index_cpanratings.pl b/elasticsearch/index_cpanratings.pl deleted file mode 100755 index ca89f89bd..000000000 --- a/elasticsearch/index_cpanratings.pl +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/perl - -=head2 SYNOPSIS - -Loads module ratings into module table. Requires the following file -in the /perl directory: - -http://cpanratings.perl.org/csv/all_ratings.csv - -=cut - -use Data::Dump qw( dump ); -use Find::Lib '../lib'; -use MetaCPAN; -use Modern::Perl; -use Parse::CSV; -use Path::Class::File; -use WWW::Mechanize::Cached; - -my $es = MetaCPAN->new->es; -my $filename = '/tmp/all_ratings.csv'; -my $file = Path::Class::File->new( $filename ); -my $mech = WWW::Mechanize::Cached->new; - -$mech->get( 'http://cpanratings.perl.org/csv/all_ratings.csv' ); -my $fh = $file->openw(); -print $fh $mech->content; - -my $parser = Parse::CSV->new( - file => $filename, - fields => 'auto', -); - -my @to_insert = (); - -while ( my $rating = $parser->fetch ) { - - my $dist_name = $rating->{distribution}; - - my $data = { - dist => $rating->{distribution}, - rating => $rating->{rating}, - review_count => $rating->{review_count}, - }; - - my %es_insert = ( - index => { - index => 'cpan', - type => 'cpanratings', - id => $rating->{distribution}, - data => $data - } - ); - - push @to_insert, \%es_insert; - -} - -my $result = $es->bulk( \@to_insert ); - -unlink $filename; diff --git a/elasticsearch/index_dists.pl b/elasticsearch/index_dists.pl deleted file mode 100755 index 57ea991b7..000000000 --- a/elasticsearch/index_dists.pl +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env perl - -use Modern::Perl; -use Data::Dump qw( dump ); -use Every; -use Find::Lib '../lib'; -use MetaCPAN; -use MetaCPAN::Dist; -use Time::HiRes qw( gettimeofday tv_interval ); - -my $t_begin = [gettimeofday]; - -my $attempts = 0; -my $every = 20; -my $cpan = MetaCPAN->new_with_options; -$cpan->check_db; - -$cpan->debug( $ENV{'DEBUG'} ); - -my $dists = []; - -my $total_dists = 1; -say $cpan->dist_name; - -if ( $cpan->dist_like ) { - say "searching for dists like: " . $cpan->dist_like; - $dists = search_dists( { dist => { like => $cpan->dist_like, '!=' => undef, } } ); -} - -elsif ( $cpan->dist_name ) { - say "searching for dist: " . $cpan->dist_name; - $dists = search_dists( { dist => $cpan->dist_name } ); -} - -else { - say "search all dists"; - $dists = search_dists(); -} - -foreach my $dist ( @{$dists} ) { - process_dist( $dist ); -} - -my $t_elapsed = tv_interval( $t_begin, [gettimeofday] ); -say "Entire process took $t_elapsed"; - -sub process_dist { - - my $distvname = shift; - my $t0 = [gettimeofday]; - - say '+' x 20 . " DIST: $distvname" if $cpan->debug; - - my $dist = MetaCPAN::Dist->new( distvname => $distvname, module_rs => $cpan->module_rs ); - $dist->process; - - say "Found " . scalar @{ $dist->processed } . " modules in dist"; - $dist->tar->clear if $dist->tar; - $dist = undef; - - ++$attempts; - - # diagnostics - if ( every( $every ) ) { - - my $iter_time = tv_interval( $t0, [gettimeofday] ); - my $elapsed = tv_interval( $t_begin, [gettimeofday] ); - say '#' x 78; - - say "$distvname"; # if $icpan->debug; - say "$iter_time to process dist"; - say "$elapsed so far... ($attempts dists out of $total_dists)"; - - my $seconds_per_dist = $elapsed / $attempts; - say "average $seconds_per_dist per dist"; - - my $total_duration = $seconds_per_dist * $total_dists; - my $total_hours = $total_duration / 3600; - say "estimated total time: $total_duration ($total_hours hours)"; - say '#' x 78; - - } - - - return; - -} - -sub search_dists { - - my $constraints = shift || {}; - - my $search = $cpan->module_rs->search( - $constraints, - { +select => ['distvname', 'dist',], - distinct => 1, - order_by => 'distvname ASC', - } - ); - - my %dist = ( ); - while ( my $row = $search->next ) { - $dist{ $row->dist } = $row->distvname; - } - - my @dists = sort values %dist; - - $total_dists = scalar @dists; - say "found $total_dists distros"; - - return \@dists; - -} - diff --git a/elasticsearch/loop_dists.pl b/elasticsearch/loop_dists.pl deleted file mode 100644 index b77014494..000000000 --- a/elasticsearch/loop_dists.pl +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env perl - -use Modern::Perl; -use Find::Lib '../lib'; -use MetaCPAN; - -=head1 SYNOPSIS - -To start with a new database. - -perl elasticsearch/loop_dists.pl --refresh_db 1 - -Keep in mind that the startup overhead is greater in this case as all modules -must first be inserted into the SQLite db. - -=cut - - -# start with a clean db -my $refresh = 0; -my $cpan = MetaCPAN->new( refresh_db => $refresh); -$cpan->check_db; - -$| = 1; - -foreach my $alpha (reverse( 'a' .. 'z' ) ) { - my $command = sprintf("/home/olaf/cpan-api/elasticsearch/index_dists.pl --dist_like %s%%", $alpha); - say $alpha; - `$command`; -} diff --git a/elasticsearch/map_modules.pl b/elasticsearch/map_modules.pl deleted file mode 100755 index c69477003..000000000 --- a/elasticsearch/map_modules.pl +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/perl - -=head1 SYNOPSIS - -Rework module mappings. - -=cut - -use Modern::Perl; - -use Data::Dump qw( dump ); -use Find::Lib '../lib'; -use MetaCPAN; - -my $metacpan = MetaCPAN->new(); -my $es = $metacpan->es; - -put_mapping(); - -sub put_mapping { - - $es->delete_mapping( - index => ['cpan'], - type => 'module', - ); - - my $result = $es->put_mapping( - index => ['cpan'], - type => 'module', - - #_source => { compress => 1 }, - properties => { - archive => { type => "string" }, - author => { type => "string" }, - distname => { type => "string" }, - distvname => { type => "string" }, - download_url => { type => "string" }, - name => { type => "string" }, - release_date => { type => "date" }, - source_url => { type => "string" }, - version => { type => "string" }, - } - ); - -} - diff --git a/elasticsearch/restart_server.pl b/elasticsearch/restart_server.pl deleted file mode 100755 index 9a7f7f4a0..000000000 --- a/elasticsearch/restart_server.pl +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env perl - -use Modern::Perl; -use Find::Lib '../lib'; -use MetaCPAN; - -my $es = MetaCPAN->new->es; - -my $result = $es->restart( -# nodes => multi, - delay => '5s' # optional -); diff --git a/es/account/mapping.json b/es/account/mapping.json new file mode 100644 index 000000000..1d89039a6 --- /dev/null +++ b/es/account/mapping.json @@ -0,0 +1,33 @@ +{ + "dynamic": false, + "properties": { + "access_token": { + "dynamic": true, + "properties": { + "client": { + "type": "keyword" + }, + "token": { + "type": "keyword" + } + } + }, + "code": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "identity": { + "dynamic": false, + "properties": { + "key": { + "type": "keyword" + }, + "name": { + "type": "keyword" + } + } + } + } +} diff --git a/es/account/settings.json b/es/account/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/account/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/author/mapping.json b/es/author/mapping.json new file mode 100644 index 000000000..d643dfe50 --- /dev/null +++ b/es/author/mapping.json @@ -0,0 +1,115 @@ +{ + "dynamic": false, + "properties": { + "asciiname": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "blog": { + "dynamic": true, + "properties": { + "feed": { + "type": "keyword" + }, + "url": { + "type": "keyword" + } + } + }, + "city": { + "type": "keyword" + }, + "country": { + "type": "keyword" + }, + "donation": { + "dynamic": true, + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + } + } + }, + "email": { + "type": "keyword" + }, + "gravatar_url": { + "type": "keyword" + }, + "is_pause_custodial_account": { + "type": "boolean" + }, + "location": { + "type": "geo_point" + }, + "name": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "pauseid": { + "type": "keyword" + }, + "perlmongers": { + "dynamic": true, + "properties": { + "name": { + "type": "keyword" + }, + "url": { + "type": "keyword" + } + } + }, + "profile": { + "dynamic": false, + "include_in_root": true, + "properties": { + "id": { + "fields": { + "analyzed": { + "analyzer": "simple", + "fielddata": false, + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "name": { + "type": "keyword" + } + }, + "type": "nested" + }, + "region": { + "type": "keyword" + }, + "updated": { + "format": "strict_date_optional_time||epoch_millis", + "type": "date" + }, + "user": { + "type": "keyword" + }, + "website": { + "type": "keyword" + } + } +} diff --git a/es/author/settings.json b/es/author/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/author/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/contributor/mapping.json b/es/contributor/mapping.json new file mode 100644 index 000000000..72017a284 --- /dev/null +++ b/es/contributor/mapping.json @@ -0,0 +1,23 @@ +{ + "dynamic": false, + "properties": { + "distribution": { + "type": "keyword" + }, + "email": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "pauseid": { + "type": "keyword" + }, + "release_author": { + "type": "keyword" + }, + "release_name": { + "type": "keyword" + } + } +} diff --git a/es/contributor/settings.json b/es/contributor/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/contributor/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/cover/mapping.json b/es/cover/mapping.json new file mode 100644 index 000000000..274adddc1 --- /dev/null +++ b/es/cover/mapping.json @@ -0,0 +1,34 @@ +{ + "dynamic": false, + "properties": { + "criteria": { + "dynamic": true, + "properties": { + "branch": { + "type": "float" + }, + "condition": { + "type": "float" + }, + "statement": { + "type": "float" + }, + "subroutine": { + "type": "float" + }, + "total": { + "type": "float" + } + } + }, + "distribution": { + "type": "keyword" + }, + "release": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } +} diff --git a/es/cover/settings.json b/es/cover/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/cover/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/cve/mapping.json b/es/cve/mapping.json new file mode 100644 index 000000000..8ec674c24 --- /dev/null +++ b/es/cve/mapping.json @@ -0,0 +1,36 @@ +{ + "dynamic": false, + "properties": { + "affected_versions": { + "type": "text" + }, + "cpansa_id": { + "type": "keyword" + }, + "cves": { + "type": "text" + }, + "description": { + "type": "text" + }, + "distribution": { + "type": "keyword" + }, + "references": { + "type": "text" + }, + "releases": { + "type": "keyword" + }, + "reported": { + "format": "strict_date_optional_time||epoch_millis", + "type": "date" + }, + "severity": { + "type": "text" + }, + "versions": { + "type": "keyword" + } + } +} diff --git a/es/cve/settings.json b/es/cve/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/cve/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/distribution/mapping.json b/es/distribution/mapping.json new file mode 100644 index 000000000..1e652f69b --- /dev/null +++ b/es/distribution/mapping.json @@ -0,0 +1,109 @@ +{ + "dynamic": false, + "properties": { + "bugs": { + "dynamic": true, + "properties": { + "github": { + "dynamic": true, + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + }, + "open": { + "type": "integer" + }, + "source": { + "type": "keyword" + } + } + }, + "rt": { + "dynamic": true, + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + }, + "new": { + "type": "integer" + }, + "open": { + "type": "integer" + }, + "patched": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "resolved": { + "type": "integer" + }, + "source": { + "type": "keyword" + }, + "stalled": { + "type": "integer" + } + } + } + } + }, + "external_package": { + "dynamic": true, + "properties": { + "cygwin": { + "type": "keyword" + }, + "debian": { + "type": "keyword" + }, + "fedora": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "repo": { + "dynamic": true, + "properties": { + "github": { + "dynamic": true, + "properties": { + "stars": { + "type": "integer" + }, + "watchers": { + "type": "integer" + } + } + } + } + }, + "river": { + "dynamic": true, + "properties": { + "bucket": { + "type": "integer" + }, + "bus_factor": { + "type": "integer" + }, + "immediate": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + } +} diff --git a/es/distribution/settings.json b/es/distribution/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/distribution/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/favorite/mapping.json b/es/favorite/mapping.json new file mode 100644 index 000000000..0aa0f6e88 --- /dev/null +++ b/es/favorite/mapping.json @@ -0,0 +1,24 @@ +{ + "dynamic": false, + "properties": { + "author": { + "type": "keyword" + }, + "date": { + "format": "strict_date_optional_time||epoch_millis", + "type": "date" + }, + "distribution": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "release": { + "type": "keyword" + }, + "user": { + "type": "keyword" + } + } +} diff --git a/es/favorite/settings.json b/es/favorite/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/favorite/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/file/mapping.json b/es/file/mapping.json new file mode 100644 index 000000000..12dc23970 --- /dev/null +++ b/es/file/mapping.json @@ -0,0 +1,242 @@ +{ + "dynamic": false, + "properties": { + "abstract": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "author": { + "type": "keyword" + }, + "authorized": { + "type": "boolean" + }, + "binary": { + "type": "boolean" + }, + "date": { + "format": "strict_date_optional_time||epoch_millis", + "type": "date" + }, + "deprecated": { + "type": "boolean" + }, + "description": { + "type": "text" + }, + "dir": { + "type": "keyword" + }, + "directory": { + "type": "boolean" + }, + "dist_fav_count": { + "type": "integer" + }, + "distribution": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + }, + "camelcase": { + "analyzer": "camelcase", + "store": true, + "type": "text" + }, + "lowercase": { + "analyzer": "lowercase", + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "documentation": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + }, + "camelcase": { + "analyzer": "camelcase", + "store": true, + "type": "text" + }, + "edge": { + "analyzer": "edge", + "store": true, + "type": "text" + }, + "edge_camelcase": { + "analyzer": "edge_camelcase", + "store": true, + "type": "text" + }, + "lowercase": { + "analyzer": "lowercase", + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "documentation_length": { + "type": "integer" + }, + "download_url": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "indexed": { + "type": "boolean" + }, + "level": { + "type": "integer" + }, + "maturity": { + "type": "keyword" + }, + "mime": { + "type": "keyword" + }, + "module": { + "dynamic": false, + "include_in_root": true, + "properties": { + "associated_pod": { + "type": "text" + }, + "authorized": { + "type": "boolean" + }, + "indexed": { + "type": "boolean" + }, + "name": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + }, + "camelcase": { + "analyzer": "camelcase", + "store": true, + "type": "text" + }, + "lowercase": { + "analyzer": "lowercase", + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "version": { + "type": "keyword" + }, + "version_numified": { + "type": "float" + } + }, + "type": "nested" + }, + "name": { + "type": "keyword" + }, + "path": { + "type": "keyword" + }, + "pod": { + "analyzer": "standard", + "fields": { + "analyzed": { + "analyzer": "standard", + "type": "text" + } + }, + "type": "text" + }, + "pod_lines": { + "type": "keyword" + }, + "release": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + }, + "camelcase": { + "analyzer": "camelcase", + "store": true, + "type": "text" + }, + "lowercase": { + "analyzer": "lowercase", + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "sloc": { + "type": "integer" + }, + "slop": { + "type": "integer" + }, + "stat": { + "dynamic": true, + "properties": { + "gid": { + "type": "long" + }, + "mode": { + "type": "integer" + }, + "mtime": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "uid": { + "type": "long" + } + } + }, + "status": { + "type": "keyword" + }, + "suggest": { + "analyzer": "simple", + "max_input_length": 50, + "preserve_position_increments": true, + "preserve_separators": true, + "type": "completion" + }, + "version": { + "type": "keyword" + }, + "version_numified": { + "type": "float" + } + } +} diff --git a/es/file/settings.json b/es/file/settings.json new file mode 100644 index 000000000..1cdf6e2f0 --- /dev/null +++ b/es/file/settings.json @@ -0,0 +1,53 @@ +{ + "analysis": { + "analyzer": { + "camelcase": { + "filter": [ + "lowercase", + "unique" + ], + "tokenizer": "camelcase", + "type": "custom" + }, + "edge": { + "filter": [ + "lowercase", + "edge" + ], + "tokenizer": "standard", + "type": "custom" + }, + "edge_camelcase": { + "filter": [ + "lowercase", + "edge" + ], + "tokenizer": "camelcase", + "type": "custom" + }, + "fulltext": { + "type": "english" + }, + "lowercase": { + "filter": "lowercase", + "tokenizer": "keyword" + } + }, + "filter": { + "edge": { + "max_gram": "20", + "min_gram": "1", + "type": "edge_ngram" + } + }, + "tokenizer": { + "camelcase": { + "pattern": "([^\\p{L}\\d]+)|(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)|(?<=[\\p{L}&&[^\\p{Lu}]])(?=\\p{Lu})|(?<=\\p{Lu})(?=\\p{Lu}[\\p{L}&&[^\\p{Lu}]])", + "type": "pattern" + } + } + }, + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/mirror/mapping.json b/es/mirror/mapping.json new file mode 100644 index 000000000..eaebd2742 --- /dev/null +++ b/es/mirror/mapping.json @@ -0,0 +1,118 @@ +{ + "dynamic": false, + "properties": { + "A_or_CNAME": { + "type": "keyword" + }, + "aka_name": { + "type": "keyword" + }, + "ccode": { + "type": "keyword" + }, + "city": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "contact": { + "dynamic": false, + "properties": { + "contact_site": { + "type": "keyword" + }, + "contact_user": { + "type": "keyword" + } + } + }, + "continent": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "country": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "dnsrr": { + "type": "keyword" + }, + "freq": { + "type": "keyword" + }, + "ftp": { + "type": "keyword" + }, + "http": { + "type": "keyword" + }, + "inceptdate": { + "format": "strict_date_optional_time||epoch_millis", + "type": "date" + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword" + }, + "note": { + "type": "keyword" + }, + "org": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "region": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "reitredate": { + "format": "strict_date_optional_time||epoch_millis", + "type": "date" + }, + "rsync": { + "type": "keyword" + }, + "src": { + "type": "keyword" + }, + "tz": { + "type": "keyword" + } + } +} diff --git a/es/mirror/settings.json b/es/mirror/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/mirror/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/package/mapping.json b/es/package/mapping.json new file mode 100644 index 000000000..0248dfe82 --- /dev/null +++ b/es/package/mapping.json @@ -0,0 +1,23 @@ +{ + "dynamic": false, + "properties": { + "author": { + "type": "keyword" + }, + "dist_version": { + "type": "keyword" + }, + "distribution": { + "type": "keyword" + }, + "file": { + "type": "keyword" + }, + "module_name": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } +} diff --git a/es/package/settings.json b/es/package/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/package/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/permission/mapping.json b/es/permission/mapping.json new file mode 100644 index 000000000..31c2e36b6 --- /dev/null +++ b/es/permission/mapping.json @@ -0,0 +1,14 @@ +{ + "dynamic": false, + "properties": { + "co_maintainers": { + "type": "keyword" + }, + "module_name": { + "type": "keyword" + }, + "owner": { + "type": "keyword" + } + } +} diff --git a/es/permission/settings.json b/es/permission/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/permission/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/release/mapping.json b/es/release/mapping.json new file mode 100644 index 000000000..4ab1b1a28 --- /dev/null +++ b/es/release/mapping.json @@ -0,0 +1,211 @@ +{ + "dynamic": false, + "properties": { + "abstract": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "archive": { + "type": "keyword" + }, + "author": { + "type": "keyword" + }, + "authorized": { + "type": "boolean" + }, + "changes_file": { + "type": "keyword" + }, + "checksum_md5": { + "type": "keyword" + }, + "checksum_sha256": { + "type": "keyword" + }, + "date": { + "format": "strict_date_optional_time||epoch_millis", + "type": "date" + }, + "dependency": { + "dynamic": false, + "include_in_root": true, + "properties": { + "module": { + "type": "keyword" + }, + "phase": { + "type": "keyword" + }, + "relationship": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + }, + "type": "nested" + }, + "deprecated": { + "type": "boolean" + }, + "distribution": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + }, + "camelcase": { + "analyzer": "camelcase", + "store": true, + "type": "text" + }, + "lowercase": { + "analyzer": "lowercase", + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "download_url": { + "type": "keyword" + }, + "first": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "license": { + "type": "keyword" + }, + "main_module": { + "type": "keyword" + }, + "maturity": { + "type": "keyword" + }, + "name": { + "fields": { + "analyzed": { + "analyzer": "standard", + "fielddata": false, + "store": true, + "type": "text" + }, + "camelcase": { + "analyzer": "camelcase", + "store": true, + "type": "text" + }, + "lowercase": { + "analyzer": "lowercase", + "store": true, + "type": "text" + } + }, + "type": "keyword" + }, + "provides": { + "type": "keyword" + }, + "resources": { + "dynamic": true, + "include_in_root": true, + "properties": { + "bugtracker": { + "dynamic": true, + "include_in_root": true, + "properties": { + "mailto": { + "type": "keyword" + }, + "web": { + "type": "keyword" + } + }, + "type": "nested" + }, + "homepage": { + "type": "keyword" + }, + "license": { + "type": "keyword" + }, + "repository": { + "dynamic": true, + "include_in_root": true, + "properties": { + "type": { + "type": "keyword" + }, + "url": { + "type": "keyword" + }, + "web": { + "type": "keyword" + } + }, + "type": "nested" + } + }, + "type": "nested" + }, + "stat": { + "dynamic": true, + "properties": { + "gid": { + "type": "long" + }, + "mode": { + "type": "integer" + }, + "mtime": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "uid": { + "type": "long" + } + } + }, + "status": { + "type": "keyword" + }, + "tests": { + "dynamic": true, + "properties": { + "fail": { + "type": "integer" + }, + "na": { + "type": "integer" + }, + "pass": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "version": { + "type": "keyword" + }, + "version_numified": { + "type": "float" + } + } +} diff --git a/es/release/settings.json b/es/release/settings.json new file mode 100644 index 000000000..1cdf6e2f0 --- /dev/null +++ b/es/release/settings.json @@ -0,0 +1,53 @@ +{ + "analysis": { + "analyzer": { + "camelcase": { + "filter": [ + "lowercase", + "unique" + ], + "tokenizer": "camelcase", + "type": "custom" + }, + "edge": { + "filter": [ + "lowercase", + "edge" + ], + "tokenizer": "standard", + "type": "custom" + }, + "edge_camelcase": { + "filter": [ + "lowercase", + "edge" + ], + "tokenizer": "camelcase", + "type": "custom" + }, + "fulltext": { + "type": "english" + }, + "lowercase": { + "filter": "lowercase", + "tokenizer": "keyword" + } + }, + "filter": { + "edge": { + "max_gram": "20", + "min_gram": "1", + "type": "edge_ngram" + } + }, + "tokenizer": { + "camelcase": { + "pattern": "([^\\p{L}\\d]+)|(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)|(?<=[\\p{L}&&[^\\p{Lu}]])(?=\\p{Lu})|(?<=\\p{Lu})(?=\\p{Lu}[\\p{L}&&[^\\p{Lu}]])", + "type": "pattern" + } + } + }, + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/session/mapping.json b/es/session/mapping.json new file mode 100644 index 000000000..d4f130c22 --- /dev/null +++ b/es/session/mapping.json @@ -0,0 +1,3 @@ +{ + "dynamic": false +} diff --git a/es/session/settings.json b/es/session/settings.json new file mode 100644 index 000000000..bbb95c38b --- /dev/null +++ b/es/session/settings.json @@ -0,0 +1,5 @@ +{ + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/es/settings.json b/es/settings.json new file mode 100644 index 000000000..1cdf6e2f0 --- /dev/null +++ b/es/settings.json @@ -0,0 +1,53 @@ +{ + "analysis": { + "analyzer": { + "camelcase": { + "filter": [ + "lowercase", + "unique" + ], + "tokenizer": "camelcase", + "type": "custom" + }, + "edge": { + "filter": [ + "lowercase", + "edge" + ], + "tokenizer": "standard", + "type": "custom" + }, + "edge_camelcase": { + "filter": [ + "lowercase", + "edge" + ], + "tokenizer": "camelcase", + "type": "custom" + }, + "fulltext": { + "type": "english" + }, + "lowercase": { + "filter": "lowercase", + "tokenizer": "keyword" + } + }, + "filter": { + "edge": { + "max_gram": "20", + "min_gram": "1", + "type": "edge_ngram" + } + }, + "tokenizer": { + "camelcase": { + "pattern": "([^\\p{L}\\d]+)|(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)|(?<=[\\p{L}&&[^\\p{Lu}]])(?=\\p{Lu})|(?<=\\p{Lu})(?=\\p{Lu}[\\p{L}&&[^\\p{Lu}]])", + "type": "pattern" + } + } + }, + "number_of_replicas": 1, + "number_of_shards": 1, + "refresh_interval": "1s" +} diff --git a/git/hooks/pre-commit b/git/hooks/pre-commit new file mode 100755 index 000000000..9256ae257 --- /dev/null +++ b/git/hooks/pre-commit @@ -0,0 +1,15 @@ +#!/bin/bash + +declare -i status +status=0 + +PRECIOUS=$(which precious) +if [[ -z $PRECIOUS ]]; then + PRECIOUS=./bin/precious +fi + +if ! "$PRECIOUS" lint -s; then + status+=1 +fi + +exit $status diff --git a/git/setup.sh b/git/setup.sh new file mode 100755 index 000000000..da4cc2e94 --- /dev/null +++ b/git/setup.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +chmod +x git/hooks/pre-commit +cd .git/hooks +ln -s ../../git/hooks/pre-commit diff --git a/lib/Catalyst/Action/Deserialize/MetaCPANSanitizedJSON.pm b/lib/Catalyst/Action/Deserialize/MetaCPANSanitizedJSON.pm new file mode 100644 index 000000000..e46d7b8e2 --- /dev/null +++ b/lib/Catalyst/Action/Deserialize/MetaCPANSanitizedJSON.pm @@ -0,0 +1,80 @@ +package Catalyst::Action::Deserialize::MetaCPANSanitizedJSON; + +use Moose; +use Cpanel::JSON::XS (); +use MetaCPAN::Server::QuerySanitizer (); +use namespace::autoclean; +use Try::Tiny qw( catch try ); + +extends 'Catalyst::Action::Deserialize::JSON'; + +around execute => sub { + my ( $orig, $self, $controller, $c ) = @_; + my $result; + + try { + $result = $self->$orig( $controller, $c ); + + # if sucessfully serialized + if ( $result eq '1' ) { + + # if we got something + if ( my $data = $c->req->data ) { + + # clean it + $c->req->data( + MetaCPAN::Server::QuerySanitizer->new( query => $data, ) + ->query ); + } + } + + foreach my $attr (qw( query_parameters parameters )) { + + # there's probably a more appropriate place for this + # but it's the same concept and we can reuse the error handling + if ( my $params = $c->req->$attr ) { + + # ES also accepts the content in the querystring + if ( exists $params->{source} ) { + if ( my $source = delete $params->{source} ) { + + # NOTE: merge $controller->{json_options} if we ever use it + my $json = Cpanel::JSON::XS->new->utf8; + + # if it decodes + if ( try { $source = $json->decode($source); } ) { + + # clean it + $source = MetaCPAN::Server::QuerySanitizer->new( + query => $source, )->query; + + # update the $req + $params->{source} = $json->encode($source); + $c->req->$attr($params); + } + } + } + } + } + } + catch { + my $e = $_[0]; + if ( try { $e->isa('MetaCPAN::Server::QuerySanitizer::Error') } ) { + + # this will return a 400 (text) through Catalyst::Action::Deserialize + $result = $e->message; + + # this is our custom version (403) that returns json + $c->detach( "/not_allowed", [ $e->message ] ); + } + else { + $result = $e; + } + }; + + return $result; +}; + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/lib/Catalyst/Action/Serialize/MetaCPANSanitizedJSON.pm b/lib/Catalyst/Action/Serialize/MetaCPANSanitizedJSON.pm new file mode 100644 index 000000000..b1bd98bc1 --- /dev/null +++ b/lib/Catalyst/Action/Serialize/MetaCPANSanitizedJSON.pm @@ -0,0 +1,8 @@ +package Catalyst::Action::Serialize::MetaCPANSanitizedJSON; + +use Moose; +extends 'Catalyst::Action::Serialize::JSON'; + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/lib/Catalyst/Authentication/Store/Proxy.pm b/lib/Catalyst/Authentication/Store/Proxy.pm new file mode 100644 index 000000000..d941a60df --- /dev/null +++ b/lib/Catalyst/Authentication/Store/Proxy.pm @@ -0,0 +1,175 @@ +package Catalyst::Authentication::Store::Proxy; + +# ABSTRACT: Delegates authentication logic to the user object +use Moose; +use Catalyst::Utils (); +use MetaCPAN::Types::TypeTiny qw( ClassName HashRef Str ); + +has user_class => ( + is => 'ro', + required => 1, + isa => Str, + lazy => 1, + builder => '_build_user_class' +); +has handles => ( is => 'ro', isa => HashRef ); +has config => ( is => 'ro', isa => HashRef ); +has app => ( is => 'ro', isa => ClassName ); +has realm => ( is => 'ro' ); + +sub BUILDARGS { + my ( $class, $config, $app, $realm ) = @_; + my $handles = { + map { $_ => $_ } qw(from_session for_session find_user), + %{ $config->{handles} || {} }, + app => $app, + realm => $realm, + }; + return { + handles => $handles, + app => $app, + realm => $realm, + $config->{user_class} ? ( user_class => $config->{user_class} ) : (), + config => $config + }; +} + +sub BUILD { + my $self = shift; + Catalyst::Utils::ensure_class_loaded( $self->user_class ); + return $self; +} + +sub _build_user_class { + shift->app . "::User"; +} + +sub new_object { + my ( $self, $c ) = @_; + return $self->user_class->new( $self->config, $c ); +} + +sub from_session { + my ( $self, $c, $frozenuser ) = @_; + my $user = $self->new_object( $self->config, $c ); + my $delegate = $self->handles->{from_session}; + return $user->$delegate( $c, $frozenuser ); +} + +sub for_session { + my ( $self, $c, $user ) = @_; + my $delegate = $self->handles->{for_session}; + return $user->$delegate($c); +} + +sub find_user { + my ( $self, $authinfo, $c ) = @_; + my $user = $self->new_object( $self->config, $c ); + my $delegate = $self->handles->{find_user}; + return $user->$delegate( $authinfo, $c ); + +} + +1; + +=head1 SYNOPSIS + + package MyApp::User; + use Moose; + extends 'Catalyst::Authentication::User'; + + sub from_session { + my ($self, $c, $id) = @_; + } + + sub for_session { + my ($self, $c) = @_; + } + + sub find_user { + my ($self, $authinfo, $c) = @_; + } + + ... + + MyApp->config( + 'Plugin::Authentication' => { + default => { + credential => { + class => 'Password', + password_type => 'none', + }, + store => { class => 'Proxy' } + } + } + ); + +=head1 DESCRIPTION + +This module makes it much easier to implement a custom +authenication store. It delegates all the necessary +method for user retrieval and session storage to a custom +user class. + +=head1 CONFIGURATION + +=head2 user_class + +Methods are delegated to this user class. It defaults to +C, where C is the name of you application. +The follwing methods have to be implemented in that class +additionally to those mentioned in +L: + +=over 4 + +=item C<< find_user ($c, $authinfo) >> + +The second argument C<$authinfo> is whatever was passed +to C<< $c->authenticate >>. If the user can be authenticated +using C<$authinfo> it has to return a new object of type +C or C. + +=item C<< from_session ($c, $id) >> + +Given a session C, this method returns an instance of +the matching C. + +=item C<< for_session ($c) >> + +This has to return a unique identifier of the user object +which will be used as second parameter to L. + +=back + +=head2 handles + + MyApp->config( + 'Plugin::Authentication' => { + default => { + credential => { ... }, + store => { + class => 'Proxy', + handles => { + find_user => 'find', + }, + } + } + } + ); + +Change the name of the authentication methods to something +else. + +=head1 SEE ALSO + +=over 4 + +=item L operates in the same way. + +=item L explains what a user class should look like. + +=item L gives a good introduction +into the authentication internals. + +=back diff --git a/lib/Catalyst/Plugin/Session/Store/ElasticSearch.pm b/lib/Catalyst/Plugin/Session/Store/ElasticSearch.pm new file mode 100644 index 000000000..68159b5cb --- /dev/null +++ b/lib/Catalyst/Plugin/Session/Store/ElasticSearch.pm @@ -0,0 +1,103 @@ +package Catalyst::Plugin::Session::Store::ElasticSearch; + +# ABSTRACT: Store session data in ElasticSearch + +use Moose; +extends 'Catalyst::Plugin::Session::Store'; +use MetaCPAN::Types::TypeTiny qw( ES ); + +use MetaCPAN::ESConfig qw( es_doc_path ); +use MetaCPAN::Server::Config (); +use MetaCPAN::Util qw( true false ); + +has _session_es => ( + is => 'ro', + lazy => 1, + coerce => 1, + isa => ES, + default => + sub { MetaCPAN::Server::Config::config()->{elasticsearch_servers} }, +); + +sub get_session_data { + my ( $self, $key ) = @_; + if ( my ($sid) = $key =~ /^\w+:(.*)/ ) { + my $data = eval { + $self->_session_es->get( es_doc_path('session'), id => $sid, ); + } || return undef; + if ( $key =~ /^expires:/ ) { + return $data->{_source}->{_expires}; + } + else { + return $data->{_source}; + } + } +} + +sub store_session_data { + my ( $self, $key, $session ) = @_; + if ( my ($sid) = $key =~ /^session:(.*)/ ) { + $session->{_expires} = $self->session_expires; + $self->_session_es->index( + es_doc_path('session'), + id => $sid, + body => $session, + refresh => true, + ); + } +} + +sub delete_session_data { + my ( $self, $key ) = @_; + if ( my ($sid) = $key =~ /^session:(.*)/ ) { + eval { + $self->_session_es->delete( + es_doc_path('session'), + id => $sid, + refresh => true, + ); + }; + } +} + +sub delete_expired_sessions { } + +1; + +=head1 SYNOPSIS + + $ curl -XPUT localhost:9200/user + $ curl -XPUT localhost:9200/user/session/_mapping -d '{"session":{"dynamic":false}}' + + use Catalyst qw( + Session + Session::Store::ElasticSearch + ); + + # defaults + MyApp->config( + 'Plugin::Session' => { + servers => ':9200', + } ); + +=head1 DESCRIPTION + +This module will store session data in ElasticSearch. ElasticSearch +is a fast and reliable document store. + +=head1 CONFIGURATION + +=head2 es + +Connection string to an ElasticSearch instance. Can either be a port +on localhost (e.g. C<:9200>), a full address to the ElasticSearch +server (e.g. C<127.0.0.1:9200>), an ArrayRef of connection strings or +a HashRef that initialized an L instance. + +=head2 index + +The ElasticSearch index to use. Defaults to C. + +=head2 type + +The ElasticSearch type to use. Defaults to C. diff --git a/lib/ElasticSearchX/Model/Document/Role.pm b/lib/ElasticSearchX/Model/Document/Role.pm new file mode 100644 index 000000000..6d2b58be2 --- /dev/null +++ b/lib/ElasticSearchX/Model/Document/Role.pm @@ -0,0 +1,24 @@ +package ElasticSearchX::Model::Document::Role; +use strict; +use warnings; + +use MetaCPAN::Model::Hacks; + +no warnings 'redefine'; + +my $_put = \&_put; +*_put = sub { + my ($self) = @_; + my $es = $self->index->model->es; + + my %return = &$_put; + + if ( $es->api_version le '6_0' ) { + return %return; + } + + delete $return{type}; + return %return; +}; + +1; diff --git a/lib/ElasticSearchX/Model/Document/Set.pm b/lib/ElasticSearchX/Model/Document/Set.pm new file mode 100644 index 000000000..7adc669ca --- /dev/null +++ b/lib/ElasticSearchX/Model/Document/Set.pm @@ -0,0 +1,63 @@ +package ElasticSearchX::Model::Document::Set; +use strict; +use warnings; + +use MetaCPAN::Model::Hacks; + +no warnings 'redefine'; + +our %query_override; +my $_build_query = \&_build_query; +*_build_query = sub { + my $query = $_build_query->(@_); + %$query = ( %$query, %query_override ); + return $query; +}; + +our %qs_override; +my $_build_qs = \&_build_qs; +*_build_qs = sub { + my $qs = $_build_qs->(@_); + %$qs = ( %$qs, %qs_override ); + return $qs; +}; + +# ESXM normally tries to use search_type => scan, which is deprecated or +# removed in newer Elasticsearch versions. Sorting on _doc gives the same +# optimization. +my $delete = \&delete; +*delete = sub { + local %qs_override = ( search_type => 'query_then_fetch' ); + local %query_override = ( sort => '_doc' ); + return $delete->(@_); +}; + +my $get = \&get; +*get = sub { + my ( $self, $args, $qs ) = @_; + if ( $self->es->api_version eq '2_0' ) { + goto &$get; + } + my %qs = %{ $qs || {} }; + if ( my $fields = $self->fields ) { + $qs{_source} = $fields; + local $self->{fields}; + return $get->( $self, $args, \%qs ); + } + goto &$get; +}; + +# ESXM will try to inflate based on the index/type stored in the result. We +# are using aliases, and ESXM doesn't know about the actual index that the +# docs are stored in. Instead, allow it to use the configured index/type for +# this doc set. +my $inflate_result = \&inflate_result; +*inflate_result = sub { + my ( $self, $res ) = @_; + my $new_res = {%$res}; + delete $new_res->{_index}; + delete $new_res->{_type}; + $self->$inflate_result($new_res); +}; + +1; diff --git a/lib/MetaCPAN.pm b/lib/MetaCPAN.pm deleted file mode 100644 index 5513e068a..000000000 --- a/lib/MetaCPAN.pm +++ /dev/null @@ -1,184 +0,0 @@ -package MetaCPAN; - -use Modern::Perl; -use Moose; -with 'MooseX::Getopt'; - -with 'MetaCPAN::Role::Common'; -with 'MetaCPAN::Role::DB'; - -use Archive::Tar; -use CPAN::DistnameInfo; -use Data::Dump qw( dump ); -use DateTime::Format::Epoch::Unix; -use ElasticSearch; -use Every; -use IO::Uncompress::AnyInflate qw(anyinflate $AnyInflateError); - -use MetaCPAN::Dist; -use MetaCPAN::Schema; - -has 'cpan' => ( - is => 'rw', - isa => 'Str', - lazy_build => 1, -); - -has 'db_path' => ( - is => 'rw', - isa => 'Str', - default => '../CPAN-meta.sqlite', -); - -has 'distvname' => ( - is => 'rw', - isa => 'Str', -); - -has 'dist_name' => ( - is => 'rw', - isa => 'Str', -); - -has 'dist_like' => ( - is => 'rw', - isa => 'Str', -); - -has 'pkg_index' => ( - is => 'rw', - isa => 'HashRef', - lazy_build => 1, -); - -has 'refresh_db' => ( - is => 'rw', - isa => 'Bool', - default => 0, -); - -sub open_pkg_index { - - my $self = shift; - my $file = $self->cpan . '/modules/02packages.details.txt.gz'; - my $tar = Archive::Tar->new; - - my $z = new IO::Uncompress::AnyInflate $file - or die "anyinflate failed: $AnyInflateError\n"; - - return $z; - -} - -sub _build_pkg_index { - - my $self = shift; - my $file = $self->open_pkg_index; - my %index = (); - - my $skip = 1; - -LINE: - while ( my $line = $file->getline ) { - if ( $skip ) { - $skip = 0 if $line eq "\n"; - next LINE; - } - - my ( $module, $version, $archive ) = split m{\s{1,}}xms, $line; - - # DistNameInfo converts 1.006001 to 1.6.1 - my $d = CPAN::DistnameInfo->new( $archive ); - - $index{$module} = { - archive => $d->pathname, - version => $d->version, - pauseid => $d->cpanid, - dist => $d->dist, - distvname => $d->distvname, - }; - } - - return \%index; - -} - -sub dist { - - my $self = shift; - - return MetaCPAN::Dist->new( - distvname => $self->distvname, - ); - -} - -sub populate { - - my $self = shift; - my $index = $self->pkg_index; - my $count = 0; - my $every = 999; - $self->module_rs->delete; - - my $inserts = 0; - my @rows = (); - foreach my $name ( sort keys %{$index} ) { - - my $module = $index->{$name}; - my %create = ( - name => $name, - download_url => 'http://cpan.metacpan.org/authors/id/' - . $module->{archive}, - release_date => $self->pkg_datestamp( $module->{archive} ), - ); - - my @cols = ( 'archive', 'pauseid', 'version', 'dist', 'distvname' ); - foreach my $col ( @cols ) { - $create{$col} = $module->{$col}; - } - - push @rows, \%create; - if ( every( $every ) ) { - $self->module_rs->populate( \@rows ); - $inserts += $every; - @rows = (); - say "$inserts rows inserted"; - } - } - - if ( scalar @rows ) { - $self->module_rs->populate( \@rows ); - $inserts += scalar @rows; - } - - return $inserts; - -} - -sub pkg_datestamp { - - my $self = shift; - my $archive = shift; - my $dist_file = "/home/cpan/CPAN/authors/id/$archive"; - my $date = ( stat( $dist_file ) )[9]; - return DateTime::Format::Epoch::Unix->parse_datetime( $date )->iso8601; - -} - -sub check_db { - - my $self = shift; - return if !$self->refresh_db; - - say "resetting db" if $self->debug; - - my $dbh = $self->schema->storage->dbh; - $dbh->do( "DELETE FROM module" ); - $dbh->do( "VACUUM" ); - - return $self->populate - -} - -1; diff --git a/lib/MetaCPAN/API.pm b/lib/MetaCPAN/API.pm new file mode 100644 index 000000000..86b29ab84 --- /dev/null +++ b/lib/MetaCPAN/API.pm @@ -0,0 +1,172 @@ +package MetaCPAN::API; + +=head1 DESCRIPTION + +This is the API Minion server. + + # Display information on jobs in queue + ./bin/run bin/api.pl minion job -s + +=cut + +use Mojo::Base 'Mojolicious'; + +use File::Temp (); +use List::Util qw( any ); +use MetaCPAN::Script::Runner (); +use Try::Tiny qw( catch try ); +use MetaCPAN::Server::Config (); + +sub startup { + my $self = shift; + + unless ( $self->config->{config_override} ) { + $self->config( MetaCPAN::Server::Config::config() ); + } + + die 'need secret' unless $self->config->{secret}; + + $self->secrets( [ $self->config->{secret} ] ); + + $self->static->paths( [ $self->home->child('root') ] ); + + $self->plugin( Minion => $self->_build_db_params ); + + $self->minion->add_task( + index_release => $self->_gen_index_task_sub('release') ); + + $self->minion->add_task( + index_latest => $self->_gen_index_task_sub('latest') ); + + $self->minion->add_task( + index_favorite => $self->_gen_index_task_sub('favorite') ); + + $self->_set_up_routes; +} + +sub _gen_index_task_sub { + my ( $self, $type ) = @_; + + return sub { + my ( $job, @args ) = @_; + + my @warnings; + local $SIG{__WARN__} = sub { + push @warnings, $_[0]; + warn $_[0]; + }; + + # @args could be ( '--latest', '/path/to/release' ); + unshift @args, $type; + + # Runner expects to have been called via CLI + local @ARGV = @args; + try { + MetaCPAN::Script::Runner->run(@args); + $job->finish( @warnings ? { warnings => \@warnings } : () ); + } + catch { + warn $_; + $job->fail( { + message => $_, + @warnings ? ( warnings => \@warnings ) : (), + } ); + }; + } +} + +sub _set_up_routes { + my $self = shift; + + my $r = $self->routes; + + my $admin = $r->under( + '/admin' => sub { + my $c = shift; + my $username = $c->session('github_username'); + if ( $self->_is_admin($username) ) { + return 1; + } + + # Direct non-admins away from the app + elsif ($username) { + $c->redirect_to('https://metacpan.org'); + return 0; + } + + # This is possibly a logged out admin + $c->redirect_to('/auth/github/authenticate'); + return 0; + } + ); + + $self->_set_up_oauth_routes; + $self->plugin( 'Minion::Admin' => { route => $admin->any('/minion') } ); +} + +sub _is_admin { + my $self = shift; + my $username = $ENV{HARNESS_ACTIVE} ? $ENV{FORCE_ADMIN_AUTH} : shift; + return 0 unless $username; + + my @admins = ( + 'haarg', 'jberger', 'mickeyn', 'oalders', + 'ranguard', 'reyjrar', 'ssoriche', + $ENV{HARNESS_ACTIVE} ? 'tester' : (), + ); + + return any { $username eq $_ } @admins; +} + +sub _build_db_params { + my $self = shift; + + my $db_params; + if ( $ENV{HARNESS_ACTIVE} ) { + my $file = File::Temp->new( UNLINK => 1, SUFFIX => '.db' ); + return { SQLite => 'sqlite:' . $file }; + } + + die "Unable to determine dsn from configuration" + unless $self->config->{minion_dsn}; + + if ( $self->config->{minion_dsn} =~ /^postgresql:/ ) { + return { Pg => $self->config->{minion_dsn} }; + } + + if ( $self->config->{minion_dsn} =~ /^sqlite:/ ) { + return { SQLite => $self->config->{minion_dsn} }; + } + + die "Unsupported Database in dsn: " . $self->config->{minion_dsn}; +} + +sub _set_up_oauth_routes { + my $self = shift; + + my $oauth = $self->config->{oauth}; + + # We could do better DRY here, but it might be more complicated than it's + # worth + + $self->plugin( + 'Web::Auth', + module => 'Github', + key => $oauth->{github}->{key}, + secret => $oauth->{github}->{secret}, + user_info => 1, + on_finished => sub { + my ( $c, $access_token, $account_info ) = @_; + my $username = $account_info->{login}; + $c->session( is_logged_in => 1 ); + $c->session( github_username => $username ); + if ( $self->_is_admin($username) ) { + $c->redirect_to('/admin'); + return; + } + $c->redirect_to( $self->config->{front_end_url} ); + }, + ); +} + +1; diff --git a/lib/MetaCPAN/API/Controller/Admin.pm b/lib/MetaCPAN/API/Controller/Admin.pm new file mode 100644 index 000000000..a7095cf89 --- /dev/null +++ b/lib/MetaCPAN/API/Controller/Admin.pm @@ -0,0 +1,15 @@ +package MetaCPAN::API::Controller::Admin; + +use Mojo::Base 'Mojolicious::Controller'; + +sub identity_search_form { } + +sub search_identities { + my $self = shift; + my $data = $self->model->user->lookup( $self->param('name'), + $self->param('key') ); + $self->stash( user_data => $data ); + $self->render('admin/search_identities'); +} + +1; diff --git a/lib/MetaCPAN/API/Model/Role/ES.pm b/lib/MetaCPAN/API/Model/Role/ES.pm new file mode 100644 index 000000000..080b0e079 --- /dev/null +++ b/lib/MetaCPAN/API/Model/Role/ES.pm @@ -0,0 +1,16 @@ +package MetaCPAN::API::Model::Role::ES; + +use Moose::Role; + +use MetaCPAN::Types::TypeTiny qw( Object ); + +has es => ( + is => 'ro', + isa => Object, + handles => { _run_query => 'search', }, + required => 1, +); + +no Moose::Role; +1; + diff --git a/lib/MetaCPAN/API/Model/User.pm b/lib/MetaCPAN/API/Model/User.pm new file mode 100644 index 000000000..5414be5c0 --- /dev/null +++ b/lib/MetaCPAN/API/Model/User.pm @@ -0,0 +1,32 @@ +package MetaCPAN::API::Model::User; + +use MetaCPAN::ESConfig qw( es_doc_path ); +use MetaCPAN::Moose; + +with 'MetaCPAN::API::Model::Role::ES'; + +sub lookup { + my ( $self, $name, $key ) = @_; + + my $query = { + bool => { + must => [ + { term => { 'identity.name' => $name } }, + { term => { 'identity.key' => $key } }, + ] + } + }; + + my $res = $self->_run_query( + es_doc_path('account'), + body => { query => $query }, + search_type => 'dfs_query_then_fetch', + ); + + return $res->{hits}{hits}[0]{_source}; +} + +__PACKAGE__->meta->make_immutable; + +1; + diff --git a/lib/MetaCPAN/API/Plugin/Model.pm b/lib/MetaCPAN/API/Plugin/Model.pm new file mode 100644 index 000000000..f33dd4b8e --- /dev/null +++ b/lib/MetaCPAN/API/Plugin/Model.pm @@ -0,0 +1,49 @@ +package MetaCPAN::API::Plugin::Model; + +use Mojo::Base 'Mojolicious::Plugin'; + +use Carp (); + +# Models from the catalyst app +use MetaCPAN::Query::Search (); + +# New models +use MetaCPAN::API::Model::Cover (); +use MetaCPAN::API::Model::Download (); +use MetaCPAN::API::Model::User (); + +has app => sub { Carp::croak 'app is required' }, weak => 1; + +has download => sub { + my $self = shift; + return MetaCPAN::API::Model::Download->new( es => $self->app->es ); +}; + +has search => sub { + my $self = shift; + return MetaCPAN::Query::Search->new( es => $self->app->es, ); +}; + +has user => sub { + my $self = shift; + return MetaCPAN::API::Model::User->new( es => $self->app->es ); +}; + +has cover => sub { + my $self = shift; + return MetaCPAN::API::Model::Cover->new( es => $self->app->es ); +}; + +sub register { + my ( $plugin, $app, $conf ) = @_; + $plugin->app($app); + + # cached models + $app->helper( 'model.download' => sub { $plugin->download } ); + $app->helper( 'model.search' => sub { $plugin->search } ); + $app->helper( 'model.user' => sub { $plugin->user } ); + $app->helper( 'model.cover' => sub { $plugin->cover } ); +} + +1; + diff --git a/lib/MetaCPAN/Author.pm b/lib/MetaCPAN/Author.pm deleted file mode 100755 index d8f4e5ca9..000000000 --- a/lib/MetaCPAN/Author.pm +++ /dev/null @@ -1,138 +0,0 @@ -package MetaCPAN::Author; - -use Moose; -use Modern::Perl; - -with 'MetaCPAN::Role::Common'; - -=head1 SYNOPSIS - -Loads author info into db. Requires the presence of a local CPAN/minicpan. - -=cut - -use Data::Dump qw( dump ); -use Find::Lib '../lib'; -use Gravatar::URL; -use Hash::Merge qw( merge ); -use IO::File; -use IO::Uncompress::AnyInflate qw(anyinflate $AnyInflateError); -use JSON::DWIW; -use MooseX::Getopt; -use Scalar::Util qw( reftype ); - -use MetaCPAN; -my $metacpan = MetaCPAN->new; - -has 'author_fh' => ( is => 'rw', lazy_build => 1, ); - -sub index_authors { - - my $self = shift; - my @authors = (); - my $author_fh = $self->author_fh; - - while ( my $line = $author_fh->getline() ) { - - if ( $line =~ m{alias\s([\w\-]*)\s{1,}"(.*)<(.*)>"}gxms ) { - - my ( $pauseid, $name, $email ) = ( $1, $2, $3 ); - my $dir = sprintf( "%s/%s/%s", - substr( $pauseid, 0, 1 ), - substr( $pauseid, 0, 2 ), $pauseid ); - - my $author = { - author => $pauseid, - pauseid => $pauseid, - author_dir => "id/$dir", - name => $name, - email => $email, - gravatar_url => gravatar_url( email => $email ), - }; - - my $conf = $self->author_config( $pauseid, $dir ); - if ( $conf ) { - $author = merge( $author, $conf ); - } - - my %es_insert = ( - index => { - index => 'cpan', - type => 'author', - id => $pauseid, - data => $author, - } - ); - - push @authors, \%es_insert; - #if ( $pauseid eq 'OALDERS' ) { - # say dump( $conf ); - # #exit; - # last; - #} - - } - } - - return $metacpan->es->bulk( \@authors ); - -} - -sub author_config { - - my $self = shift; - my $pauseid = shift; - my $dir = shift; - my $file = Find::Lib::base . "/../conf/authors/$dir/author.json"; - - return if !-e $file; - - my $json = JSON::DWIW->new; - my ( $authors, $error_msg ) = $json->from_json_file( $file, {} ); - my $conf = $authors->{$pauseid}; - - # uncomment this when search.metacpan can deal with lists in values - #my @lists = qw( website email books blog_url blog_feed cats dogs ); - #foreach my $key ( @lists ) { - # if ( exists $conf->{$key} && reftype( $conf->{$key} ) ne 'ARRAY' ) { - # $conf->{$key} = [ $conf->{$key} ]; - # } - #} - - return $conf; - -} - -sub _build_author_fh { - - my $self = shift; - my $file = $self->cpan . "/authors/01mailrc.txt.gz"; - - return new IO::Uncompress::AnyInflate $file - or die "anyinflate failed: $AnyInflateError\n"; - -} - -1; - -=pod - -=head1 SYNOPSIS - -Parse out CPAN author info, add custom per-author metadata and add it to the -ElasticSearch index - - my $author = MetaCPAN::Author->new; - my $result = $author->index_authors; - -=head2 author_config( $pauseid, $dir ) - -Returns custom author metadata if any exists. - - my $conf = $author->author_config( 'OALDERS', 'O/OA/OALDERS' ) - -=head2 index_authors - -Adds/updates all authors in the CPAN index to ElasticSearch. - -=cut diff --git a/lib/MetaCPAN/Dist.pm b/lib/MetaCPAN/Dist.pm deleted file mode 100644 index 00ddd5e48..000000000 --- a/lib/MetaCPAN/Dist.pm +++ /dev/null @@ -1,528 +0,0 @@ -package MetaCPAN::Dist; - -use Archive::Tar; -use Archive::Tar::Wrapper; -use Devel::SimpleTrace; -use File::Slurp; -use Moose; -use MooseX::Getopt; -use Modern::Perl; -use Data::Dump qw( dump ); -use Try::Tiny; - -use MetaCPAN::Pod::XHTML; - -with 'MetaCPAN::Role::Author'; -with 'MetaCPAN::Role::Common'; -with 'MetaCPAN::Role::DB'; - -has 'archive_parent' => ( is => 'rw', ); - -has 'distvname' => ( - is => 'rw', - required => 1, -); - -has 'es_inserts' => ( - is => 'rw', - isa => 'ArrayRef', - default => sub { return [] }, -); - -has 'file' => ( is => 'rw', ); - -has 'files' => ( - is => 'ro', - isa => "HashRef", - lazy_build => 1, -); - -has 'module' => ( is => 'rw', isa => 'MetaCPAN::Schema::Result::Module' ); - -has 'module_rs' => ( is => 'rw' ); - -has 'name' => ( - is => 'rw', - lazy_build => 1, -); - -has 'pm_name' => ( - is => 'rw', - lazy_build => 1, -); - -has 'processed' => ( - is => 'rw', - isa => 'ArrayRef', - default => sub{ [] }, -); - -has 'tar' => ( - is => 'rw', - lazy_build => 1, -); - -has 'tar_class' => ( - is => 'rw', - default => 'Archive::Tar', -); - -has 'tar_wrapper' => ( - is => 'rw', - lazy_build => 1, -); - -sub _build_path { - my $self = shift; - return $self->meta->archive; -} - -sub archive_path { - - my $self = shift; - return $self->cpan . "/authors/id/" . $self->module->archive; - -} - -sub process { - - my $self = shift; - my $success = 0; - my $module_rs = $self->module_rs->search({ distvname => $self->distvname }); - - my @modules = (); - while ( my $found = $module_rs->next ) { - push @modules, $found; - } - -MODULE: - - #while ( my $found = $module_rs->next ) { - foreach my $found ( @modules ) { - - $self->module( $found ); - say "checking dist " . $found->name if $self->debug; - - # take an educated guess at the correct file before we go through the - # entire list - # some dists (like BioPerl, have no lib folder) - - foreach my $source_folder ( 'lib/', '' ) { - my $base_guess = $source_folder . $found->name; - $base_guess =~ s{::}{/}g; - - foreach my $extension ( '.pm', '.pod' ) { - my $guess = $base_guess . $extension; - say "*" x 10 . " about to guess: $guess" if $self->debug; - if ( $self->parse_pod( $found->name, $guess ) ) { - say "*" x 10 . " found guess: $guess" if $self->debug; - ++$success; - next MODULE; - } - - } - } - - } - - $self->index_dist; - $self->process_cookbooks; - - if ( $self->es_inserts ) { - my $result = $self->es->bulk( $self->es_inserts ); - #say dump( $self->es_inserts ); - } - - elsif ( $self->debug ) { - warn $self->name . " no success" . "!" x 20; - return; - } - - $self->tar->clear if $self->tar; - - return; - -} - -sub process_cookbooks { - - my $self = shift; - say ">" x 20 . "looking for cookbooks" if $self->debug; - - foreach my $file ( sort keys %{ $self->files } ) { - next if ( $file !~ m{\Alib(.*)\.pod\z} ); - - my $module_name = $self->file2mod( $file ); - - # update ->module for each cookbook file. otherwise it gets indexed - # under the wrong module name - my %cols = $self->module->get_columns; - delete $cols{xhtml_pod}; - delete $cols{id}; - $cols{name} = $module_name; - $cols{file} = $file; - - $self->module( $self->module_rs->find_or_create(\%cols) ); - my %new_cols = $self->module->get_columns; - - my $success = $self->parse_pod( $module_name, $file ); - say '=' x 20 . "cookbook ok: " . $file if $self->debug; - } - - return; - -} - -sub get_content { - - my $self = shift; - my $module_name = shift; - my $filename = shift; - my $pm_name = $self->pm_name; - - return if !exists $self->files->{$filename}; - - # not every module contains POD - my $file = $self->archive_parent . $filename; - my $content = undef; - - if ( $self->tar_class eq 'Archive::Tar' ) { - $content - = $self->tar->get_content( $self->archive_parent . $filename ); - } - else { - my $location = $self->tar_wrapper->locate( $file ); - - if ( !$location ) { - say "skipping: $file does not found in archive" if $self->debug; - return; - } - - $content = read_file( $location ); - } - - if ( !$content || $content !~ m{=head} ) { - say "skipping -- no POD -- $filename" if $self->debug; - delete $self->files->{$filename}; - return; - } - - if ( $filename !~ m{\.pod\z} && $content !~ m{package\s*$module_name} ) { - say "skipping -- not the correct package name" if $self->debug; - return; - } - - say "got pod ok: $filename "; - return $content; - -} - -sub parse_pod { - - my $self = shift; - my $module_name = shift; - my $file = shift; - my $module = $self->module; - - my $content = $self->get_content( $module_name, $file ); - say $file; - - if ( !$content ) { - say "No content found for $file" if $self->debug; - return; - } - - my $parser = MetaCPAN::Pod::XHTML->new(); - - $parser->index( 1 ); - $parser->html_header( '' ); - $parser->html_footer( '' ); - $parser->perldoc_url_prefix( '' ); - - my $xhtml = ""; - $parser->output_string( \$xhtml ); - $parser->parse_string_document( $content ); - - #$module->xhtml_pod( $xhtml ); - $module->file( $file ); - $module->update; - - my %pod_insert = ( - index => { - index => 'cpan', - type => 'pod', - id => $module_name, - data => { pod => $xhtml }, - } - ); - - #my %cols = $module->get_columns; - #say dump( \%cols ); - - $self->index_module( $file ); - - push @{ $self->es_inserts }, \%pod_insert; - - # if this line is uncommented some pod, like Dancer docs gets skipped - delete $self->files->{$file}; - push @{$self->processed}, $file; - - return 1; - -} - -sub index_dist { - - my $self = shift; - my $module = $self->module; - my $dist_name = $module->distvname; - $dist_name =~ s{\-\d.*}{}g; - - my $data = { name => $dist_name, author => $module->pauseid }; - my @cols = ( 'download_url', 'archive', 'release_date', 'version', - 'distvname' ); - - foreach my $col ( @cols ) { - $data->{$col} = $module->$col; - } - - my %es_insert = ( - index => { - index => 'cpan', - type => 'dist', - id => $dist_name, - data => $data, - } - ); - - push @{ $self->es_inserts }, \%es_insert; - -} - -sub index_module { - - my $self = shift; - my $file = shift; - my $module = $self->module; - my $dist_name = $module->distvname; - $dist_name =~ s{\-\d.*}{}g; - - my $src_url = sprintf( 'http://search.metacpan.org/source/%s/%s/%s', - $module->pauseid, $module->distvname, $module->file ); - - my $data = { - name => $module->name, - source_url => $src_url, - distname => $dist_name, - author => $module->pauseid, - }; - my @cols - = ( 'download_url', 'archive', 'release_date', 'version', 'distvname', - ); - - foreach my $col ( @cols ) { - $data->{$col} = $module->$col; - } - - my %es_insert = ( - index => { - index => 'cpan', - type => 'module', - id => $module->name, - data => $data, - } - ); - - #say dump( \%es_insert ); - push @{ $self->es_inserts }, \%es_insert; - -} - -sub get_files { - - my $self = shift; - my @files = (); - - if ( $self->tar_class eq 'Archive::Tar' ) { - my $tar = $self->tar; - eval { $tar->read( $self->archive_path ) }; - if ( $@ ) { - warn $@; - return []; - } - - @files = $tar->list_files; - } - - else { - for my $entry ( @{ $self->tar_wrapper->list_all() } ) { - my ( $tar_path, $real_path ) = @$entry; - push @files, $tar_path; - } - } - - return \@files; - -} - -sub _build_files { - - my $self = shift; - my $files = $self->get_files; - my @files = @{$files}; - return {} if scalar @files == 0; - - my %files = (); - - $self->set_archive_parent( $files ); - - if ( $self->debug ) { - my %cols = $self->module->get_columns; - say dump( \%cols ) if $self->debug; - } - - foreach my $file ( @files ) { - if ( $file =~ m{\.(pod|pm)\z}i ) { - - my $parent = $self->archive_parent; - $file =~ s{\A$parent}{}; - - next if $file =~ m{\At\/}; # avoid test modules - - # avoid POD we can't properly name - next if $file =~ m{\.pod\z} && $file !~ m{\Alib\/}; - - $files{$file} = 1; - } - } - - say dump( \%files ) if $self->debug; - return \%files; - -} - -sub _build_metadata { - - my $self = shift; - return $self->module_rs->search( { distvname => $self->distvname } )->first; - -} - -sub _build_tar { - - my $self = shift; - say "archive path: " . $self->archive_path if $self->debug; - my $tar = undef; - try { $tar = Archive::Tar->new( $self->archive_path ) }; - - if ( !$tar ) { - say "*" x 30 . ' no tar object created for ' . $self->archive_path; - return 0; - } - - if ( $tar->error ) { - say "*" x 30 . ' tar error: ' . $tar->error; - return 0; - } - - return $tar; - -} - -sub _build_tar_wrapper { - - my $self = shift; - my $arch = Archive::Tar::Wrapper->new(); - - $arch->read( $self->archive_path ); - - $arch->list_reset(); - return $arch; - -} - -sub _build_pm_name { - my $self = shift; - return $self->_module_root . '.pm'; -} - -sub _build_pod_name { - my $self = shift; - return $self->_module_root . '.pod'; -} - -sub _module_root { - my $self = shift; - my @module_parts = split( "::", $self->module->name ); - return pop( @module_parts ); -} - -sub set_archive_parent { - - my $self = shift; - my $files = shift; - - # is there one parent folder for all files? - my %parent = ( ); - foreach my $file ( @{$files} ) { - my @parts = split "/", $files->[0]; - my $top = shift @parts; - - # some dists expand to: ./AFS-2.6.2/src/Utils/Utils.pm - $top .= '/' . shift @parts if ( $top eq '.' ); - $parent{$top} = 1; - } - - my @folders = keys %parent; - - if ( scalar @folders == 1 ) { - $self->archive_parent( $folders[0] . '/' ); - } - - say "parent " . ":" x 20 . $self->archive_parent if $self->debug; - - return; - -} - -1; - -=pod - -=head1 SYNOPSIS - -We only care about modules which are in the very latest version of the distro. -For example, the minicpan (and CPAN) indices, show something like this: - -Moose::Meta::Attribute::Native 1.17 D/DR/DROLSKY/Moose-1.17.tar.gz -Moose::Meta::Attribute::Native::MethodProvider::Array 1.14 D/DR/DROLSKY/Moose-1.14.tar.gz - -We don't care about modules which are no longer included in the latest -distribution, so we'll only import POD from the highest version number of any -distro we're searching on. - -=head2 archive_path - -Full file path to module archive. - -=cut - -=head2 process - -Do the heavy lifting here. First take an educated guess at where the module -should be. After that, look at every available file to find a match. - -=head2 process_cookbooks - -Because manuals and cookbook pages don't appear in the minicpan index, they -were passed over previous to 1.0.2 - -This should be run on any files left over in the distribution. - -Distributions which have .pod files outside of lib folders will be skipped, -since there's often no clear way of discerning which modules (if any) those -docs explicitly pertain to. - -=cut - - diff --git a/lib/MetaCPAN/Document/Author.pm b/lib/MetaCPAN/Document/Author.pm new file mode 100644 index 000000000..48296bb18 --- /dev/null +++ b/lib/MetaCPAN/Document/Author.pm @@ -0,0 +1,236 @@ +package MetaCPAN::Document::Author; + +use MetaCPAN::Moose; + +# load order important for next 2 modules +use ElasticSearchX::Model::Document::Types qw( Location ); +use ElasticSearchX::Model::Document; + +# load order not important +use Gravatar::URL (); +use MetaCPAN::Types qw( ESBool Profile ); +use MetaCPAN::Types::TypeTiny qw( + ArrayRef + ArrayRefPromote + Blog + Dict + HashRef + NonEmptySimpleStr + PerlMongers + Str +); +use MetaCPAN::Util qw(true false); + +has name => ( + is => 'ro', + required => 1, + index => 'analyzed', + isa => NonEmptySimpleStr, +); + +has asciiname => ( + is => 'ro', + required => 1, + index => 'analyzed', + isa => Str, + default => q{}, +); + +has [qw(website email)] => + ( is => 'ro', required => 1, isa => ArrayRefPromote, coerce => 1 ); + +has pauseid => ( + is => 'ro', + required => 1, + id => 1, +); + +has user => ( + is => 'ro', + writer => '_set_user', + clearer => '_clear_user', +); + +has gravatar_url => ( + is => 'ro', + isa => NonEmptySimpleStr, + lazy => 1, + builder => '_build_gravatar_url', +); + +has profile => ( + is => 'ro', + isa => Profile, + coerce => 1, + type => 'nested', + include_in_root => 1, +); + +has blog => ( + is => 'ro', + isa => Blog, + coerce => 1, + dynamic => 1, +); + +has perlmongers => ( + is => 'ro', + isa => PerlMongers, + coerce => 1, + dynamic => 1, +); + +has donation => ( + is => 'ro', + isa => ArrayRef [ Dict [ name => NonEmptySimpleStr, id => Str ] ], + dynamic => 1, +); + +has [qw(city region country)] => ( is => 'ro', isa => NonEmptySimpleStr ); + +has location => ( is => 'ro', isa => Location, coerce => 1 ); + +has extra => ( + is => 'ro', + isa => HashRef, + source_only => 1, + dynamic => 1, +); + +has updated => ( + is => 'ro', + isa => Str, +); + +has is_pause_custodial_account => ( + is => 'ro', + isa => ESBool, + coerce => 1, + default => sub {false}, +); + +sub _build_gravatar_url { + my $self = shift; + + # We do not use the author personal address ($self->email[0]) + # because we want to show the author's CPAN identity. + # Using another e-mail than the CPAN one removes flexibility for + # the author and ultimately could be a privacy leak. + # The author can manage this identity both on their gravatar account + # (by assigning an image to their author@cpan.org) + # and now by changing this URL from metacpa.org + return Gravatar::URL::gravatar_url( + email => $self->pauseid . '@cpan.org', + size => 130, + https => 1, + + # Fallback to a generated image + default => 'identicon', + ); +} + +sub validate { + my ( $class, $data ) = @_; + my @result; + foreach my $attr ( $class->meta->get_all_attributes ) { + if ( $attr->is_required && !exists $data->{ $attr->name } ) { + push( + @result, + { + field => $attr->name, + message => $attr->name . ' is required' + } + ); + } + elsif ( exists $data->{ $attr->name } && $attr->has_type_constraint ) + { + my $value = $data->{ $attr->name }; + if ( $attr->should_coerce ) { + $value = $attr->type_constraint->coerce($value); + } + my $message = $attr->type_constraint->validate($value); + push( @result, { field => $attr->name, message => $message } ) + if ( defined $message ); + } + } + return @result; +} + +__PACKAGE__->meta->make_immutable; +1; + +=pod + +=head1 PROPERTIES + +=head2 email + +=head2 website + +=head2 city + +=head2 region + +=head2 country + +=head2 name + +=head2 name.analyzed + +Self explanatory. + +=head2 pauseid + +PAUSE ID of the author. + +=head2 dir + +Directory of the author. +Example: C<< id/P/PE/PERLER >> + +=head2 gravatar_url + +URL to the gravatar user picture. This URL is generated using PAUSEID@cpan.org. + +=head2 profile + +Object or array of user profiles. Example: + + [ { name => "amazon", id => "B002MRC39U" }, + { name => "stackoverflow", id => "brian-d-foy" } ] + +=head2 blog + +Object or array of blogs. Example: + + { feed => "http://blogs.perl.org/users/brian_d_foy/atom.xml", + url => "http://blogs.perl.org/users/brian_d_foy/" } + +=head2 perlmongers + +Object or array of perlmonger groups. Example: + + { url => "http://frankfurt.pm", name => "Frankfurt.pm" } + +=head2 donation + +Object or array of places where to donate. Example: + + { name => "paypal", id => "brian.d.foy@gmail.com" } + +=head2 location + +Array of longitude and latitude. Example: + + [12.5436, 7.2358] + +=head2 extra + +=head2 extra.analyzed + +This field can contain anything. It is serialized using JSON +and stored in the index. You can do full-text searches on the +analyzed JSON string. + +=cut + diff --git a/lib/MetaCPAN/Document/Author/Profile.pm b/lib/MetaCPAN/Document/Author/Profile.pm new file mode 100644 index 000000000..497828b07 --- /dev/null +++ b/lib/MetaCPAN/Document/Author/Profile.pm @@ -0,0 +1,28 @@ +package MetaCPAN::Document::Author::Profile; + +use strict; +use warnings; + +use Moose; +use ElasticSearchX::Model::Document; + +with 'ElasticSearchX::Model::Document::EmbeddedRole'; + +use MetaCPAN::Types::TypeTiny qw( Str ); +use MetaCPAN::Util; + +has name => ( + is => 'ro', + isa => Str, + required => 1, +); + +has id => ( + is => 'ro', + isa => Str, + analyzer => ['simple'], +); + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/lib/MetaCPAN/Document/CVE.pm b/lib/MetaCPAN/Document/CVE.pm new file mode 100644 index 000000000..155b1b57a --- /dev/null +++ b/lib/MetaCPAN/Document/CVE.pm @@ -0,0 +1,63 @@ +package MetaCPAN::Document::CVE; + +use MetaCPAN::Moose; + +use ElasticSearchX::Model::Document; +use MetaCPAN::Types::TypeTiny qw( ArrayRef Str ); + +has distribution => ( + is => 'ro', + isa => Str, + required => 1, +); + +has cpansa_id => ( + is => 'ro', + isa => Str, + required => 1, +); + +has description => ( + is => 'ro', + isa => Str, + required => 1, +); + +has severity => ( + is => 'ro', + isa => Str, + required => 1, +); + +has reported => ( + is => 'ro', + isa => Str, + required => 1, +); + +has affected_versions => ( + is => 'ro', + isa => ArrayRef, + required => 1, +); + +has cves => ( + is => 'ro', + isa => ArrayRef, + required => 1, +); + +has references => ( + is => 'ro', + isa => ArrayRef, + required => 1, +); + +has versions => ( + is => 'ro', + isa => ArrayRef, + required => 1, +); + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Document/Contributor.pm b/lib/MetaCPAN/Document/Contributor.pm new file mode 100644 index 000000000..dec572834 --- /dev/null +++ b/lib/MetaCPAN/Document/Contributor.pm @@ -0,0 +1,42 @@ +package MetaCPAN::Document::Contributor; + +use MetaCPAN::Moose; + +use ElasticSearchX::Model::Document; +use MetaCPAN::Types::TypeTiny qw( ArrayRef Str ); + +has distribution => ( + is => 'ro', + isa => Str, + required => 1, +); + +has release_author => ( + is => 'ro', + isa => Str, + required => 1, +); + +has release_name => ( + is => 'ro', + isa => Str, + required => 1, +); + +has pauseid => ( + is => 'ro', + isa => Str, +); + +has name => ( + is => 'ro', + isa => Str, +); + +has email => ( + is => 'ro', + isa => ArrayRef [Str], +); + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Document/Cover.pm b/lib/MetaCPAN/Document/Cover.pm new file mode 100644 index 000000000..1ab9a5991 --- /dev/null +++ b/lib/MetaCPAN/Document/Cover.pm @@ -0,0 +1,33 @@ +package MetaCPAN::Document::Cover; + +use MetaCPAN::Moose; + +use ElasticSearchX::Model::Document; +use MetaCPAN::Types::TypeTiny qw( HashRef Str ); + +has distribution => ( + is => 'ro', + isa => Str, + required => 1, +); + +has release => ( + is => 'ro', + isa => Str, + required => 1, +); + +has version => ( + is => 'ro', + isa => Str, + required => 1, +); + +has criteria => ( + is => 'ro', + isa => HashRef, + required => 1, +); + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Document/Dependency.pm b/lib/MetaCPAN/Document/Dependency.pm new file mode 100644 index 000000000..e54f85fd9 --- /dev/null +++ b/lib/MetaCPAN/Document/Dependency.pm @@ -0,0 +1,16 @@ +package MetaCPAN::Document::Dependency; + +use strict; +use warnings; + +use Moose; +use ElasticSearchX::Model::Document; + +with 'ElasticSearchX::Model::Document::EmbeddedRole'; + +use MetaCPAN::Util; + +has [qw(phase relationship module version)] => ( is => 'ro', required => 1 ); + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Document/Distribution.pm b/lib/MetaCPAN/Document/Distribution.pm new file mode 100644 index 000000000..64c178664 --- /dev/null +++ b/lib/MetaCPAN/Document/Distribution.pm @@ -0,0 +1,66 @@ +package MetaCPAN::Document::Distribution; + +use strict; +use warnings; +use namespace::autoclean; + +use Moose; +use ElasticSearchX::Model::Document; + +use MetaCPAN::Types::TypeTiny qw( BugSummary RiverSummary ); +use MetaCPAN::Util qw(true false); + +has name => ( + is => 'ro', + required => 1, + id => 1, +); + +has bugs => ( + is => 'ro', + isa => BugSummary, + dynamic => 1, + writer => '_set_bugs', +); + +has river => ( + is => 'ro', + isa => RiverSummary, + dynamic => 1, + writer => '_set_river', + default => sub { + +{ + bucket => 0, + bus_factor => 1, + immediate => 0, + total => 0, + }; + }, +); + +sub releases { + my $self = shift; + return $self->index->model->doc("release") + ->query( { term => { "distribution" => $self->name } } ); +} + +sub set_first_release { + my $self = shift; + + my @releases = $self->releases->sort( ["date"] )->all; + + my $first = shift @releases; + $first->_set_first(true); + $first->put; + + for my $rel (@releases) { + $rel->_set_first(false); + $rel->put; + } + + return $first; +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/lib/MetaCPAN/Document/Favorite.pm b/lib/MetaCPAN/Document/Favorite.pm new file mode 100644 index 000000000..eaf7cbcf1 --- /dev/null +++ b/lib/MetaCPAN/Document/Favorite.pm @@ -0,0 +1,36 @@ +package MetaCPAN::Document::Favorite; + +use strict; +use warnings; + +use Moose; +use ElasticSearchX::Model::Document; + +use DateTime (); +use MetaCPAN::Util; + +has id => ( + is => 'ro', + id => [qw(user distribution)], +); + +has [qw(author release user distribution)] => ( + is => 'ro', + required => 1, +); + +=head2 date + +L when the item was created. + +=cut + +has date => ( + is => 'ro', + required => 1, + isa => 'DateTime', + default => sub { DateTime->now }, +); + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Document/File.pm b/lib/MetaCPAN/Document/File.pm new file mode 100644 index 000000000..c9fd79c8a --- /dev/null +++ b/lib/MetaCPAN/Document/File.pm @@ -0,0 +1,1004 @@ +package MetaCPAN::Document::File; + +use strict; +use warnings; +use utf8; + +use Moose; +use ElasticSearchX::Model::Document; + +use List::Util qw( any ); +use MetaCPAN::Document::Module (); +use MetaCPAN::Types qw( ESBool Module ); +use MetaCPAN::Types::TypeTiny qw( ArrayRef Int Maybe Num ScalarRef Stat Str ); +use MetaCPAN::Util qw(numify_version true false); +use Plack::MIME (); +use Pod::Text (); +use Try::Tiny qw( catch try ); + +Plack::MIME->add_type( '.t' => 'text/x-script.perl' ); +Plack::MIME->add_type( '.pod' => 'text/x-pod' ); +Plack::MIME->add_type( '.xs' => 'text/x-c' ); + +my @NOT_PERL_FILES = qw(SIGNATURE); + +sub BUILD { + my $self = shift; + + # force building of `mime` + $self->_build_mime; +} + +=head1 PROPERTIES + +=head2 deprecated + +Indicates file deprecation (the abstract contains "DEPRECATED" or "DEPRECIATED", +or the x_deprecated flag is included in the corresponding "provides" entry in distribution metadata); +it is also set if the entire release is marked deprecated (see L). + +=cut + +has deprecated => ( + is => 'ro', + isa => ESBool, + default => sub {false}, + writer => '_set_deprecated', +); + +=head2 abstract + +Abstract of the documentation (if any). This is built by parsing the +C section. It also sets L if it succeeds. + +=cut + +has section => ( + is => 'ro', + isa => Maybe [Str], + lazy => 1, + builder => '_build_section', + property => 0, +); + +my $RE_SECTION = qr/^\s*(\S+)((\h+-+\h+(.+))|(\r?\n\h*\r?\n\h*(.+)))?/ms; + +sub _build_section { + my $self = shift; + + my $text = ${ $self->content }; + my $section = MetaCPAN::Util::extract_section( $text, 'NAME' ); + + # if it's a POD file without a name section, let's try to generate + # an abstract and name based on filename + if ( !$section && $self->path =~ /\.pod$/ ) { + $section = $self->path; + $section =~ s{^(lib|pod|docs)/}{}; + $section =~ s{\.pod$}{}; + $section =~ s{/}{::}g; + } + + return undef unless ($section); + $section =~ s/^=\w+.*$//mg; + $section =~ s/X<.*?>//mg; + + return $section; +} + +has abstract => ( + required => 1, + is => 'ro', + isa => Maybe [Str], + lazy => 1, + builder => '_build_abstract', + index => 'analyzed', +); + +sub _build_abstract { + my $self = shift; + return undef unless ( $self->is_perl_file ); + + my $section = $self->section; + return undef unless $section; + + my $abstract; + + if ( $section =~ $RE_SECTION ) { + chomp( $abstract = $4 || $6 ) if ( $4 || $6 ); + } + if ($abstract) { + $abstract =~ s/^=\w+.*$//xms; + $abstract =~ s{\r?\n\h*\r?\n\h*.*$}{}xms; + $abstract =~ s{\n}{ }gxms; + $abstract =~ s{\s+$}{}gxms; + $abstract =~ s{(\s)+}{$1}gxms; + $abstract = MetaCPAN::Util::strip_pod($abstract); + } + return $abstract; +} + +=head2 id + +Unique identifier of the release. +Consists of the L's pauseid, the release L, +and the file path. +See L. + +=cut + +has id => ( + is => 'ro', + id => [qw(author release path)], +); + +=head2 module + +An ArrayRef of L objects, that represent +modules defined in that class (i.e. package declarations). + +=cut + +has module => ( + is => 'ro', + isa => Module, + type => 'nested', + include_in_root => 1, + coerce => 1, + clearer => 'clear_module', + writer => '_set_module', + lazy => 1, + default => sub { [] }, +); + +=head2 download_url + +B + +Download URL of the release + +=cut + +has download_url => ( + is => 'ro', + required => 1 +); + +=head2 date + +B + +Release date (i.e. C of the archive file). + +=cut + +has date => ( + is => 'ro', + required => 1, + isa => 'DateTime', +); + +=head2 description + +Contains the C section of the POD if any. Will be stripped from +whitespaces and POD commands. + +=cut + +has description => ( + required => 1, + is => 'ro', + lazy => 1, + builder => '_build_description', + index => 'not_analyzed', +); + +sub _build_description { + my $self = shift; + return undef unless ( $self->is_perl_file ); + my $section + = MetaCPAN::Util::extract_section( ${ $self->content }, + 'DESCRIPTION' ); + return undef unless ($section); + + my $parser = Pod::Text->new; + my $text = q{}; + $parser->output_string( \$text ); + + try { + $parser->parse_string_document("=pod\n\n$section"); + } + catch { + warn $_; + }; + + return undef unless $text; + + $text =~ s/\s+/ /g; + $text =~ s/^\s+//; + $text =~ s/\s+$//; + return $text; +} + +=head2 distribution + +=head2 distribution.analyzed + +=head2 distribution.camelcase + +Name of the distribution (e.g. C). + +=head2 author + +PAUSE ID of the author. + +=head2 status + +Valid values are C, C, and C. The most recent upload +of a distribution is tagged as C as long as it's not a developer +release, unless there are only developer releases. Everything else is +tagged C. Once a release is deleted from PAUSE it is tagged as +C. + +=cut + +has status => ( is => 'ro', required => 1, default => 'cpan' ); + +=head2 binary + +File is binary or not. + +=cut + +has binary => ( + is => 'ro', + isa => ESBool, + required => 1, + default => sub {false}, +); + +=head2 authorized + +See L. + +=cut + +has authorized => ( + required => 1, + is => 'ro', + isa => ESBool, + default => sub {true}, + writer => '_set_authorized', +); + +=head2 maturity + +Maturity of the release. This can either be C or C. +See L. + +=cut + +has maturity => ( + is => 'ro', + required => 1, + default => 'released', +); + +=head2 directory + +Return true if this object represents a directory. + +=cut + +has directory => ( + is => 'ro', + required => 1, + isa => ESBool, + default => sub {false}, +); + +=head2 documentation + +Holds the name for the documentation in this file. + +If the file L, the name is derived from the +C section. If the file L and the +name from the C section matches one of the modules in L, +it returns the name. Otherwise it returns the name of the first module +in L. If there are no modules in the file the documentation is +set to C. + +=cut + +has documentation => ( + is => 'ro', + isa => Maybe [Str], + lazy => 1, + index => 'analyzed', + builder => '_build_documentation', + predicate => 'has_documentation', + analyzer => [qw(standard camelcase lowercase edge edge_camelcase)], + clearer => 'clear_documentation', + writer => '_set_documentation', +); + +sub _build_documentation { + my $self = shift; + return undef unless ( $self->is_perl_file ); + + my $section = $self->section; + return undef unless $section; + + my $documentation; + + if ( $section =~ $RE_SECTION ) { + my $name = MetaCPAN::Util::strip_pod($1); + $documentation = $name if ( $name =~ /^[\w\.:\-_']+$/ ); + } + + $documentation = MetaCPAN::Util::strip_pod($documentation) + if $documentation; + + return undef unless length $documentation; + + # Modules to be indexed + my @indexed = grep { $_->indexed } @{ $self->module || [] }; + + # This is a Pod file, return its name + if ( $documentation && $self->is_pod_file ) { + return $documentation; + } + + # OR: found an indexed module with the same name + if ( $documentation && grep { $_->name eq $documentation } @indexed ) { + return $documentation; + } + + # OR: found an indexed module with a name + if ( my ($mod) = grep { defined $_->name } @indexed ) { + return $mod->name; + } + + # OR: we have a parsed documentation + return $documentation if defined $documentation; + + # OR: found ANY module with a name (better than nothing) + if ( my ($mod) = grep { defined $_->name } @{ $self->module || [] } ) { + return $mod->name; + } + + return undef; +} + +has documentation_length => ( + is => 'ro', + isa => Maybe [Int], + lazy => 1, + builder => '_build_documentation_length', +); + +sub _build_documentation_length { + my ($self) = @_; + return length( $self->documentation ); +} + +=head2 suggest + +Autocomplete info for documentation. + +=cut + +has suggest => ( + is => 'ro', + + # isa => Maybe [HashRef], # remarked: breaks the suggester + lazy => 1, + builder => '_build_suggest', +); + +sub _build_suggest { + my $self = shift; + my $doc = $self->documentation; + + # return +{} unless $doc; # remarked because of 'isa' + return unless $doc; + + my $weight = 1000 - length($doc); + $weight = 0 if $weight < 0; + + return +{ + input => [$doc], + weight => $weight, + }; +} + +=head2 indexed + +B + +Indicates whether the file should be included in the search index or +not. See L for a more verbose explanation. + +=cut + +has indexed => ( + required => 1, + is => 'ro', + isa => ESBool, + lazy => 1, + default => sub { + my ($self) = @_; + return false if $self->is_in_other_files; + return false if !$self->metadata->should_index_file( $self->path ); + return true; + }, + writer => '_set_indexed', +); + +=head2 level + +Level of this file in the directory tree of the release (i.e. C +has a level of C<0>). + +=cut + +has level => ( + required => 1, + is => 'ro', + isa => Int, + lazy => 1, + builder => '_build_level', +); + +sub _build_level { + my $self = shift; + my @level = split( /\//, $self->path ); + return @level - 1; +} + +=head2 pod + +Pure text format of the pod (see L). Consecutive whitespaces +are removed to save space and for better snippet previews. + +=cut + +has pod => ( + required => 1, + is => 'ro', + isa => ScalarRef, + lazy => 1, + builder => '_build_pod', + index => 'analyzed', + not_analyzed => 0, + store => 'no', + term_vector => 'with_positions_offsets', +); + +sub _build_pod { + my $self = shift; + return \'' unless ( $self->is_perl_file ); + + my $parser = Pod::Text->new( sentence => 0, width => 78 ); + + # We don't need to index pod errors. + $parser->no_errata_section(1); + + my $content = ${ $self->content }; + + # The pod parser is very liberal and will "start" a pod document when it + # sees /^=[a-zA-Z]/ even though it might be binary like /^=F\0?\{/. + # So munge any lines that might match but are not usual pod directives + # that people would use (we don't need to index non-regular pod). + # Also see the test and comments in t/document/file.t for how + # bizarre constructs are handled. + + $content =~ s/ + # Pod::Simple::parse_string_document() "supports \r, \n ,\r\n"... + (?: + \A|\r|\r\n|\n) # beginning of line + \K # (keep those characters) + + ( + =[a-zA-Z][a-zA-Z0-9]* # looks like pod + (?! # but followed by something that isn't pod: + [a-zA-Z0-9] # more pod chars (the star won't be greedy enough) + | \s # whitespace ("=head1 NAME\n", "=item\n") + | \Z # end of line or end of doc + ) + ) + + # Prefix (to hide from Pod parser) instead of removing. + /\0$1/gx; + + my $text = ""; + $parser->output_string( \$text ); + + try { + $parser->parse_string_document($content); + } + catch { + warn $_[0]; + }; + + $text =~ s/\s+/ /g; + $text =~ s/ \z//; + + # Remove any markers we put in the text. + # Should we remove other non-regular bytes that may come from the source? + $text =~ s/\0//g; + + return \$text; +} + +=head2 pod_lines + +ArrayRef of ArrayRefs of offset and length of pod blocks. Example: + + # Two blocks of pod, starting at line 1 and line 15 with length + # of 10 lines each + [[1,10], [15,10]] + +=cut + +has pod_lines => ( + is => 'ro', + isa => ArrayRef, + type => 'integer', + lazy => 1, + builder => '_build_pod_lines', + index => 'no', +); + +sub _build_pod_lines { + my $self = shift; + return [] unless ( $self->is_perl_file ); + my ( $lines, $slop ) = MetaCPAN::Util::pod_lines( ${ $self->content } ); + $self->_set_slop( $slop || 0 ); + return $lines; +} + +=head2 sloc + +Source Lines of Code. Strips empty lines, pod and C section from +L and returns the number of lines. + +=cut + +has sloc => ( + required => 1, + is => 'ro', + isa => Int, + lazy => 1, + builder => '_build_sloc', +); + +# Metrics from Perl::Metrics2::Plugin::Core. +sub _build_sloc { + my $self = shift; + return 0 unless ( $self->is_perl_file ); + + my @content = split( "\n", ${ $self->content } ); + my $pods = 0; + + # Use pod_lines data to remove pod content from string. + map { + splice( @content, $_->[0], $_->[1], map {''} 1 .. $_->[1] ) + } @{ $self->pod_lines }; + + my $sloc = 0; + while (@content) { + my $line = shift @content; + last if ( $line =~ /^\s*__(DATA|END)__/s ); + $sloc++ if ( $line !~ /^\s*#/ && $line =~ /\S/ ); + } + return $sloc; +} + +=head2 slop + +Source Lines of Pod. Returns the number of pod lines using L. + +=cut + +has slop => ( + required => 1, + is => 'ro', + isa => Int, + lazy => 1, + builder => '_build_slop', + writer => '_set_slop', +); + +sub _build_slop { + my $self = shift; + return 0 unless ( $self->is_perl_file ); + $self->_build_pod_lines; + + # danger! infinite recursion if not set by `_build_pod_lines` + # we should probably find a better solution -- Mickey + return $self->slop; +} + +=head2 stat + +L info of the archive file. Contains C, +C and C. + +=cut + +has stat => ( + is => 'ro', + isa => Stat, + dynamic => 1, +); + +=head2 version + +Contains the raw version string. + +=cut + +has version => ( is => 'ro', ); + +=head2 version_numified + +B + +Numeric representation of L. Contains 0 if there is no version or the +version could not be parsed. + +=cut + +has version_numified => ( + required => 1, + is => 'ro', + isa => Num, + lazy => 1, + builder => '_build_version_numified', +); + +sub _build_version_numified { + my $self = shift; + return numify_version( $self->version ); +} + +=head2 mime + +MIME type of file. Derived using L (for speed). + +=cut + +has mime => ( + required => 1, + is => 'ro', + lazy => 1, + builder => '_build_mime', +); + +sub _build_mime { + my $self = shift; + if ( !$self->directory + && $self->name !~ /\./ + && grep { $self->name ne $_ } @NOT_PERL_FILES ) + { + my $content = ${ $self->content }; + return "text/x-script.perl" if ( $content =~ /^#!.*?perl/ ); + } + else { + return Plack::MIME->mime_type( $self->name ) || 'text/plain'; + } +} + +has [qw(path author name)] => ( is => 'ro', required => 1 ); + +sub _build_path { + my $self = shift; + return join( '/', $self->release->name, $self->name ); +} + +has dir => ( + is => 'ro', + isa => Str, + lazy => 1, + builder => '_build_dir', + index => 'not_analyzed' +); + +sub _build_dir { + my $self = shift; + $DB::single = 1; + my $dir = $self->path; + $dir =~ s{/[^/]+$}{}; + return $dir; +} + +has [qw(release distribution)] => ( + is => 'ro', + required => 1, + analyzer => [qw(standard camelcase lowercase)], +); + +=head1 ATTRIBUTES + +These attributes are not stored. + +=head2 content + +A scalar reference to the content of the file. + +=cut + +has content => ( + is => 'ro', + isa => ScalarRef, + lazy => 1, + default => sub { \"" }, + property => 0, +); + +=head2 local_path + +This attribute holds the path to the file on the local filesystem. + +=cut + +has local_path => ( + is => 'ro', + property => 0, +); + +=head2 metadata + +Reference to the L object of the release. + +=cut + +has metadata => ( + is => 'ro', + isa => 'CPAN::Meta', + lazy => 1, + default => sub { die 'meta attribute missing' }, + property => 0, +); + +=head1 METHODS + +=head2 is_perl_file + +Return true if the file extension is one of C, C, C, C +or if the file has no extension, is not a binary file and its size is less +than 131072 bytes. This is an arbitrary limit but it keeps the pod parser +happy and the indexer fast. + +=cut + +sub is_perl_file { + my $self = shift; + return 0 if ( $self->directory ); + return 1 if ( $self->name =~ /\.(pl|pm|pod|t)$/i ); + return 1 if ( $self->mime eq "text/x-script.perl" ); + return 1 + if ( $self->name !~ /\./ + && !( grep { $self->name eq $_ } @NOT_PERL_FILES ) + && !$self->binary + && $self->stat->{size} < 2**17 ); + return 0; +} + +=head2 is_pod_file + +Returns true if the file extension is C. + +=cut + +sub is_pod_file { + shift->name =~ /\.pod$/i; +} + +=head2 add_module + +Requires at least one parameter which can be either a HashRef or +an instance of L. + +=cut + +sub add_module { + my ( $self, @modules ) = @_; + $_ = MetaCPAN::Document::Module->new($_) + for ( grep { ref $_ eq 'HASH' } @modules ); + $self->_set_module( [ @{ $self->module }, @modules ] ); +} + +=head2 is_in_other_files + +Returns true if the file is one from the list below. + +=cut + +sub is_in_other_files { + my $self = shift; + my @other = qw( + AUTHORS + Build.PL + Changelog + ChangeLog + CHANGELOG + Changes + CHANGES + CONTRIBUTING + CONTRIBUTING.md + CONTRIBUTING.pod + Copying + COPYRIGHT + cpanfile + CREDITS + dist.ini + FAQ + INSTALL + INSTALL.md + INSTALL.pod + LICENSE + Makefile.PL + MANIFEST + META.json + META.yml + NEWS + README + README.md + README.pod + THANKS + Todo + ToDo + TODO + ); + + return any { $self->path eq $_ } @other; +} + +=head2 set_indexed + +Expects a C<$meta> parameter which is an instance of L. + +For each package (L) in the file and based on L +it is decided, whether the module should have a true L attribute. +If there are any packages with leading underscores, the module gets a false +L attribute, because PAUSE doesn't allow this kind of name for packages +(https://github.com/andk/pause/blob/master/lib/PAUSE/pmfile.pm#L249). + +If L returns true but the package declaration +uses the I hack, the L property is set to false. + + package # hide from PAUSE + MyTest::Module; + # will result in indexed => 0 + +Once that is done, the L property of the file is determined by searching +the list of L for a module that matches the value of L. +If there is no such module, the L property is set to false. If the file +does not include any modules, the L property is true. + +=cut + +sub set_indexed { + my ( $self, $meta ) = @_; + + # modules explicitly listed in 'provides' should be indexed + foreach my $mod ( @{ $self->module } ) { + if ( exists $meta->provides->{ $mod->name } + and $self->path eq $meta->provides->{ $mod->name }{file} ) + { + $mod->_set_indexed(true); + return; + } + } + + # files listed under 'other files' are not shown in a search + if ( $self->is_in_other_files() ) { + foreach my $mod ( @{ $self->module } ) { + $mod->_set_indexed(false); + } + $self->_set_indexed(false); + return; + } + + # files under no_index directories should not be indexed + foreach my $dir ( @{ $meta->no_index->{directory} } ) { + if ( $self->path eq $dir or $self->path =~ /^$dir\// ) { + $self->_set_indexed(false); + return; + } + } + + foreach my $mod ( @{ $self->module } ) { + if ( $mod->name !~ /^[A-Za-z]/ + or !$meta->should_index_package( $mod->name ) ) + { + $mod->_set_indexed(false); + next; + } + + $mod->_set_indexed(true); + } + + if ( my $doc_name = $self->documentation ) { + + # normalize the documentation name for comparison the same way module + # names are normalized + my $normalized_doc_name = $doc_name =~ s{'}{::}gr; + $self->_set_indexed( + ( + # .pm file with no package declaration but pod should be indexed + !@{ $self->module } || + + # don't index if the documentation doesn't match any of its modules + grep { $normalized_doc_name eq $_->name } + @{ $self->module } + ) ? true : false + ); + } +} + +=head2 set_authorized + +Expects a C<$perms> parameter which is a HashRef. The key is the module name +and the value an ArrayRef of author names who are allowed to release +that module. + +The method returns a list of unauthorized, but indexed modules. + +Unauthorized modules are modules that were uploaded in the name of a +different author than stated in the C<06perms.txt.gz> file. One problem +with this file is, that it doesn't record historical data. It may very +well be that an author was authorized to upload a module at the time. +But then his co-maintainer rights might have been revoked, making consecutive +uploads of that release unauthorized. However, since this script runs +with the latest version of C<06perms.txt.gz>, the former upload will +be flagged as unauthorized as well. Same holds the other way round, +a previously unauthorized release would be flagged authorized if the +co-maintainership was added later on. + +If a release contains unauthorized modules, the whole release is marked +as unauthorized as well. + +=cut + +sub set_authorized { + my ( $self, $perms ) = @_; + + if ( $self->distribution eq 'perl' ) { + my $allowed = grep $_ eq $self->author, @{ $perms->{perl} }; + foreach my $module ( @{ $self->module } ) { + $module->_set_authorized( $allowed ? true : false ); + } + $self->_set_authorized( $allowed ? true : false ); + } + else { + foreach my $module ( @{ $self->module } ) { + $module->_set_authorized(false) + if ( $perms->{ $module->name } + && !grep { $_ eq $self->author } + @{ $perms->{ $module->name } } ); + } + $self->_set_authorized(false) + if ( $self->authorized + && $self->documentation + && $perms->{ $self->documentation } + && !grep { $_ eq $self->author } + @{ $perms->{ $self->documentation } } ); + } + return grep { !$_->authorized && $_->indexed } @{ $self->module }; +} + +=head2 full_path + +Concatenate L, L and L. + +=cut + +sub full_path { + my $self = shift; + return join( '/', $self->author, $self->release, $self->path ); +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Document/Mirror.pm b/lib/MetaCPAN/Document/Mirror.pm new file mode 100644 index 000000000..7b3b04e94 --- /dev/null +++ b/lib/MetaCPAN/Document/Mirror.pm @@ -0,0 +1,45 @@ +package MetaCPAN::Document::Mirror; + +use strict; +use warnings; + +use Moose; +use MooseX::Types::ElasticSearch qw( Location ); +use ElasticSearchX::Model::Document; + +use MetaCPAN::Types::TypeTiny qw( Dict Str ); + +has name => ( + is => 'ro', + required => 1, + id => 1, +); + +has [qw(org city region country continent)] => ( + is => 'ro', + index => 'analyzed', +); + +has [qw(tz src http rsync ftp freq note dnsrr ccode aka_name A_or_CNAME)] => + ( is => 'ro' ); + +has location => ( + is => 'ro', + isa => Location, + coerce => 1, +); + +has contact => ( + is => 'ro', + required => 1, + isa => Dict [ contact_site => Str, contact_user => Str ], +); + +has [qw(inceptdate reitredate)] => ( + is => 'ro', + isa => 'DateTime', + coerce => 1, +); + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Document/Module.pm b/lib/MetaCPAN/Document/Module.pm new file mode 100644 index 000000000..a3901bae1 --- /dev/null +++ b/lib/MetaCPAN/Document/Module.pm @@ -0,0 +1,169 @@ +package MetaCPAN::Document::Module; + +use strict; +use warnings; + +use Moose; +use ElasticSearchX::Model::Document; + +with 'ElasticSearchX::Model::Document::EmbeddedRole'; + +use MetaCPAN::Types qw( ESBool ); +use MetaCPAN::Types::TypeTiny qw( Maybe Num Str ); +use MetaCPAN::Util qw(true false); + +=head1 SYNOPSIS + + MetaCPAN::Document::Module->new( + { + name => "Some::Module", + version => "1.1.1" + } + ); + + +=head1 PROPERTIES + +=head2 name + +B + +=head2 name.analyzed + +=head2 name.camelcase + +Name of the module. When searching for a module it is advised to use use both +the C and the C property. + +=head2 version + +Contains the raw version string. + +=head2 indexed + +B + +Indicates whether the module should be included in the search index or +not. Releases usually exclude modules in folders like C or C +from the index. + +=head1 METHODS + +=head2 hide_from_pause( $content, $file_name ) + +Using this pragma, you can hide a module from the CPAN indexer: + + package # hide me + Foo; + +This methods searches C<$content> for the package declaration. If it's +not declared in one line, the module is considered not-indexed. + +=cut + +has name => ( + is => 'ro', + isa => Str, + required => 1, + index => 'analyzed', + analyzer => [qw(standard camelcase lowercase)], +); + +has version => ( is => 'ro' ); + +has indexed => ( + is => 'ro', + required => 1, + isa => ESBool, + default => sub {true}, + writer => '_set_indexed', +); + +has authorized => ( + is => 'ro', + required => 1, + isa => ESBool, + default => sub {true}, + writer => '_set_authorized', +); + +has associated_pod => ( + required => 1, + isa => Maybe [Str], + is => 'ro', + default => sub { }, + writer => '_set_associated_pod', +); + +has version_numified => ( + is => 'ro', + isa => Num, + lazy_build => 1, + required => 1, +); + +sub _build_version_numified { + my $self = shift; + return 0 unless ( $self->version ); + return MetaCPAN::Util::numify_version( $self->version ); +} + +my $bom + = qr/(?:\x00\x00\xfe\xff|\xff\xfe\x00\x00|\xfe\xff|\xff\xfe|\xef\xbb\xbf)/; + +=head2 set_associated_pod + +Expects an instance C<$file> of L as first parameter +and a HashRef C<$pod> which contains all files with a L +and maps those to the file names. + +L is set to the path of the file, which contains the documentation. + +=cut + +my %_pod_score = ( + pod => 50, + pm => 40, + pl => 30, +); + +sub set_associated_pod { + my ( $self, $associated_pod ) = @_; + return unless ( my $files = $associated_pod->{ $self->name } ); + + ( my $mod_path = $self->name ) =~ s{::}{/}g; + + my ($file) = ( + #<<< + # TODO: adjust score if all files are in root? + map { $_->[1] } + sort { $b->[0] <=> $a->[0] } # desc + map { + [ ( + # README.pod in root should rarely if ever be chosen. + # Typically it's there for github or something and it's usually + # a duplicate of the main module pod (though sometimes it falls + # out of sync (which makes it even worse)). + $_->path =~ /^README\.pod$/i ? -10 : + + # If the name of the package matches the name of the file, + $_->path =~ m!(^lib/)?\b${mod_path}.((?i)pod|pm)$! ? + # Score pod over pm, and boost (most points for 'lib' dir). + ($1 ? 50 : 25) + $_pod_score{lc($2)} : + + # Sort files by extension: Foo.pod > Foo.pm > foo.pl. + $_->name =~ /\.(pod|pm|pl)/i ? $_pod_score{lc($1)} : + + # Otherwise score unknown (near the bottom). + -1 + ), + $_ ] + } + @$files + #>>> + ); + $self->_set_associated_pod( $file->full_path ); +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Document/Package.pm b/lib/MetaCPAN/Document/Package.pm new file mode 100644 index 000000000..e5ee7bf9f --- /dev/null +++ b/lib/MetaCPAN/Document/Package.pm @@ -0,0 +1,25 @@ +package MetaCPAN::Document::Package; + +use MetaCPAN::Moose; + +use ElasticSearchX::Model::Document; +use MetaCPAN::Types::TypeTiny qw( Str ); + +has module_name => ( + is => 'ro', + isa => Str, + required => 1, +); + +has version => ( + is => 'ro', + isa => Str, +); + +has file => ( + is => 'ro', + isa => Str, +); + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Document/Permission.pm b/lib/MetaCPAN/Document/Permission.pm new file mode 100644 index 000000000..b72524dfd --- /dev/null +++ b/lib/MetaCPAN/Document/Permission.pm @@ -0,0 +1,25 @@ +package MetaCPAN::Document::Permission; + +use MetaCPAN::Moose; + +use ElasticSearchX::Model::Document; +use MetaCPAN::Types::TypeTiny qw( ArrayRef Str ); + +has module_name => ( + is => 'ro', + isa => Str, + required => 1, +); + +has owner => ( + is => 'ro', + isa => Str, +); + +has co_maintainers => ( + is => 'ro', + isa => ArrayRef, +); + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Document/Release.pm b/lib/MetaCPAN/Document/Release.pm new file mode 100644 index 000000000..ad92baf61 --- /dev/null +++ b/lib/MetaCPAN/Document/Release.pm @@ -0,0 +1,302 @@ +package MetaCPAN::Document::Release; + +use Moose; + +use ElasticSearchX::Model::Document; +use MetaCPAN::Types qw( Dependency ESBool ); +use MetaCPAN::Types::TypeTiny qw( + ArrayRef + HashRefCPANMeta + Num + Resources + Stat + Str + Tests +); +use MetaCPAN::Util qw( numify_version true false ); + +=head1 PROPERTIES + +=head2 id + +Unique identifier of the release. Consists of the L's pauseid and +the release L. See L. + +=head2 name + +=head2 name.analyzed + +Name of the release (e.g. C). + +=head2 distribution + +=head2 distribution.analyzed + +=head2 distribution.camelcase + +Name of the distribution (e.g. C). + +=head2 author + +PAUSE ID of the author. + +=head2 archive + +Name of the archive file (e.g. C). + +=head2 date + +B + +Release date (i.e. C of the archive file). + +=head2 version + +Contains the raw version string. + +=head2 version_numified + +Numified version of L. Contains 0 if there is no version or the +version could not be parsed. + +=head2 status + +Valid values are C, C, and C. The most recent upload +of a distribution is tagged as C as long as it's not a developer +release. Everything else is tagged C. Once a release is deleted from +PAUSE it is tagged as C. + +=head2 maturity + +Maturity of the release. This can either be C or C. +See L. + +=head2 dependency + +Array of dependencies as derived from the META file. +See L. + +=head2 resources + +See L. + +=head2 meta + +See L. Upgraded to version 2 if possible. This property +is not indexed by ElasticSearch and only available from the source. + +=head2 abstract + +Description of the release. + +=head2 license + +See L. + +=head2 stat + +L info of the archive file. Contains C, +C and C. + +=head2 first + +B; Indicates whether this is the first ever release for this distribution. + +=head2 provides + +This is an ArrayRef of modules that are included in this release. + +=cut + +=head2 deprecated + +This is a boolean indicating whether the release is marked as deprecated +(the main module's ABSTRACT contains "DEPRECATED" or "DEPRECIATED", +or the x_deprecated flag is set in metadata) + +=cut + +has provides => ( + is => 'ro', + isa => ArrayRef [Str], + writer => '_set_provides', +); + +has id => ( + is => 'ro', + id => [qw(author name)], +); + +has [qw(version author archive)] => ( + is => 'ro', + required => 1, +); + +has license => ( + is => 'ro', + isa => ArrayRef, + required => 1, +); + +has date => ( + is => 'ro', + required => 1, + isa => 'DateTime', +); + +has download_url => ( + is => 'ro', + lazy => 1, + builder => '_build_download_url', +); + +has [qw(checksum_md5 checksum_sha256)] => ( + is => 'ro', + isa => Str, +); + +has [qw(distribution name)] => ( + is => 'ro', + required => 1, + analyzer => [qw(standard camelcase lowercase)], +); + +has version_numified => ( + required => 1, + is => 'ro', + isa => Num, + lazy => 1, + default => sub { + return numify_version( shift->version ); + }, +); + +has resources => ( + is => 'ro', + isa => Resources, + coerce => 1, + dynamic => 1, + type => 'nested', + include_in_root => 1, +); + +has abstract => ( + is => 'ro', + index => 'analyzed', + predicate => 'has_abstract', + writer => '_set_abstract', +); + +has dependency => ( + is => 'ro', + isa => Dependency, + coerce => 1, + type => 'nested', + include_in_root => 1, +); + +# The initial state for a release is 'cpan'. +# The indexer scripts will upgrade it to 'latest' if it's the version in +# 02packages or downgrade it to 'backpan' if it gets deleted. +has status => ( + is => 'ro', + required => 1, + default => 'cpan', + writer => '_set_status', +); + +has maturity => ( + is => 'ro', + required => 1, + default => 'released', +); + +has stat => ( + is => 'ro', + isa => Stat, + dynamic => 1, +); + +has tests => ( + is => 'ro', + isa => Tests, + dynamic => 1, + documentation => 'HashRef: Summary of CPANTesters data', +); + +has authorized => ( + is => 'ro', + required => 1, + isa => ESBool, + default => sub {true}, + writer => '_set_authorized', +); + +has first => ( + is => 'ro', + required => 1, + isa => ESBool, + default => sub {false}, + writer => '_set_first', +); + +has metadata => ( + coerce => 1, + is => 'ro', + isa => HashRefCPANMeta, + dynamic => 1, + source_only => 1, +); + +has main_module => ( + is => 'ro', + isa => Str, + writer => '_set_main_module', +); + +has changes_file => ( + is => 'ro', + isa => Str, + writer => '_set_changes_file', +); + +has deprecated => ( + is => 'ro', + isa => ESBool, + default => sub {false}, + writer => '_set_deprecated', +); + +sub _build_download_url { + my $self = shift; + return + 'https://cpan.metacpan.org/authors/' + . MetaCPAN::Util::author_dir( $self->author ) . '/' + . $self->archive; +} + +sub set_first { + my $self = shift; + my $is_first = $self->index->model->doc('release')->query( { + bool => { + must => [ + { term => { distribution => $self->distribution } }, + { + range => { + version_numified => { lt => $self->version_numified } + } + }, + ], + }, + + # REINDEX: after a full reindex, the above line is to replaced with: + # { term => { first => 1 } }, + # currently, the "first" property is not computed on all releases + # since this feature has not been around when last reindexed + } )->count ? false : true; + + $self->_set_first($is_first); +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/ESConfig.pm b/lib/MetaCPAN/ESConfig.pm new file mode 100644 index 000000000..e84f1d928 --- /dev/null +++ b/lib/MetaCPAN/ESConfig.pm @@ -0,0 +1,261 @@ +use v5.20; +use warnings; +use experimental qw( signatures postderef ); + +package MetaCPAN::ESConfig; + +use Carp qw( croak ); +use Const::Fast qw( const ); +use Cpanel::JSON::XS (); +use Exporter qw( import ); +use Hash::Merge::Simple qw( merge ); +use MetaCPAN::Server::Config (); +use MetaCPAN::Types::TypeTiny qw( Defined HashRef ); +use MetaCPAN::Util qw( root_dir true false ); +use Module::Runtime qw( $module_name_rx require_module ); + +const my %config => merge( + { + documents => { + author => { + index => 'author', + type => 'author', + mapping => 'es/author/mapping.json', + settings => 'es/author/settings.json', + model => 'MetaCPAN::Document::Author', + }, + cve => { + index => 'cve', + type => 'cve', + mapping => 'es/cve/mapping.json', + settings => 'es/cve/settings.json', + model => 'MetaCPAN::Document::CVE', + }, + contributor => { + index => 'contributor', + type => 'contributor', + mapping => 'es/contributor/mapping.json', + settings => 'es/contributor/settings.json', + model => 'MetaCPAN::Document::Contributor', + }, + cover => { + index => 'cover', + type => 'cover', + mapping => 'es/cover/mapping.json', + settings => 'es/cover/settings.json', + model => 'MetaCPAN::Document::Cover', + }, + distribution => { + index => 'distribution', + type => 'distribution', + mapping => 'es/distribution/mapping.json', + settings => 'es/distribution/settings.json', + model => 'MetaCPAN::Document::Distribution', + }, + favorite => { + index => 'favorite', + type => 'favorite', + mapping => 'es/favorite/mapping.json', + settings => 'es/favorite/settings.json', + model => 'MetaCPAN::Document::Favorite', + }, + file => { + index => 'file', + type => 'file', + mapping => 'es/file/mapping.json', + settings => 'es/file/settings.json', + model => 'MetaCPAN::Document::File', + }, + mirror => { + index => 'mirror', + type => 'mirror', + mapping => 'es/mirror/mapping.json', + settings => 'es/mirror/settings.json', + model => 'MetaCPAN::Document::Mirror', + }, + package => { + index => 'package', + type => 'package', + mapping => 'es/package/mapping.json', + settings => 'es/package/settings.json', + model => 'MetaCPAN::Document::Package', + }, + permission => { + index => 'permission', + type => 'permission', + mapping => 'es/permission/mapping.json', + settings => 'es/permission/settings.json', + model => 'MetaCPAN::Document::Permission', + }, + release => { + index => 'release', + type => 'release', + mapping => 'es/release/mapping.json', + settings => 'es/release/settings.json', + model => 'MetaCPAN::Document::Release', + }, + + account => { + index => 'account', + type => 'account', + mapping => 'es/account/mapping.json', + settings => 'es/account/settings.json', + model => 'MetaCPAN::Model::User::Account', + }, + session => { + index => 'session', + type => 'session', + mapping => 'es/session/mapping.json', + settings => 'es/session/settings.json', + model => 'MetaCPAN::Model::User::Session', + }, + }, + }, + MetaCPAN::Server::Config::config()->{elasticsearch} || {}, +)->%*; + +{ + use Moo; +} + +has all_indexes => ( + is => 'lazy', + default => sub ($self) { + my %seen; + [ + sort + grep !$seen{$_}++, + map $_->{index}, + values $self->documents->%* + ]; + }, +); + +my $DefinedHash = ( HashRef [Defined] )->plus_coercions( + HashRef, + => sub ($hash) { + return { + map { + my $value = $hash->{$_}; + defined $value ? ( $_ => $value ) : (); + } keys %$hash + }; + }, +); + +has documents => ( + is => 'ro', + isa => HashRef [$DefinedHash], + coerce => 1, + required => 1, +); + +sub _load_es_data ( $location, $def_sub ) { + my $data; + + if ( ref $location ) { + $data = $location; + } + elsif ( $location + =~ /\A($module_name_rx)(?:::([0-9a-zA-Z_]+)\(\)|->($module_name_rx))?\z/ + ) + { + my ( $module, $sub, $method ) = ( $1, $2, $3 ); + require_module $module; + if ($method) { + $data = $module->$method; + } + else { + $sub ||= $def_sub; + no strict 'refs'; + my $code = \&{"${module}::${sub}"}; + die "can't find $location" + if !defined &$code; + $data = $code->(); + } + } + else { + my $abs_path = File::Spec->rel2abs( $location, root_dir() ); + open my $fh, '<', $abs_path + or die "can't open mapping file $abs_path: $!"; + $data = do { local $/; <$fh> }; + } + + return $data + if ref $data; + + return Cpanel::JSON::XS::decode_json($data); +} + +sub _walk : prototype(&$); + +sub _walk : prototype(&$) { + my ( $cb, $data ) = @_; + if ( ref $data eq 'HASH' ) { + $cb->($data); + _walk( \&$cb, $data->{$_} ) for keys %$data; + } + elsif ( ref $data eq 'ARRAY' ) { + $cb->($data); + _walk( \&$cb, $_ ) for @$data; + } +} + +sub mapping ( $self, $doc, $version ) { + my $doc_data = $self->documents->{$doc} + or croak "unknown document $doc"; + my $data = _load_es_data( $doc_data->{mapping}, 'mapping' ); + if ( $version && $version eq '2_0' ) { + _walk( + sub { + my ($d) = @_; + if ( my $type = $d->{type} ) { + if ( $type eq 'keyword' ) { + $d->{type} = 'string'; + $d->{index} = 'not_analyzed'; + $d->{ignore_above} = 2048; + } + elsif ( $type eq 'text' ) { + $d->{type} = 'string'; + if ( exists $d->{fielddata} && !$d->{fielddata} ) { + $d->{fielddata} = { format => false }; + } + } + } + + }, + $data + ); + } + return $data; +} + +sub index_settings ( $self, $doc, $version = undef ) { + my $documents = $self->documents; + my $doc_data = exists $documents->{$doc} && $documents->{$doc} + or return {}; + my $settings = exists $doc_data->{settings} && $doc_data->{settings} + or return {}; + my $data = _load_es_data( $settings, 'settings' ); + return $data; +} + +sub doc_path ( $self, $doc ) { + my $doc_data = $self->documents->{$doc} + or croak "unknown document $doc"; + return ( + ( $doc_data->{index} ? ( index => $doc_data->{index} ) : () ), + ( $doc_data->{type} ? ( type => $doc_data->{type} ) : () ), + ); +} + +our @EXPORT_OK = qw( + es_config + es_doc_path +); + +my $single = __PACKAGE__->new(%config); +sub es_config : prototype() {$single} +sub es_doc_path ($doc) { $single->doc_path($doc) } + +1; diff --git a/lib/MetaCPAN/Model.pm b/lib/MetaCPAN/Model.pm new file mode 100644 index 000000000..e98d5ac6e --- /dev/null +++ b/lib/MetaCPAN/Model.pm @@ -0,0 +1,38 @@ +package MetaCPAN::Model; + +# load order important +use Moose; + +use ElasticSearchX::Model; +use MetaCPAN::ESConfig qw(es_config); +use Module::Runtime qw( require_module use_package_optimistically ); + +my %indexes; +my $docs = es_config->documents; +for my $name ( sort keys %$docs ) { + my $doc = $docs->{$name}; + my $model = $doc->{model} + or next; + require_module($model); + use_package_optimistically( $model . '::Set' ); + my $index = $doc->{index} + or die "no index for $name documents!"; + + $indexes{$index}{types}{$name} = $model->meta; +} + +for my $index ( sort keys %indexes ) { + index $index => %{ $indexes{$index} }; +} + +sub doc { + my ( $self, $doc ) = @_; + my $doc_config = es_config->documents->{$doc}; + return $self->index( $doc_config->{index} ) + ->type( $doc_config->{type} // $doc_config->{index} ); +} + +__PACKAGE__->meta->make_immutable; +1; + +__END__ diff --git a/lib/MetaCPAN/Model/Archive.pm b/lib/MetaCPAN/Model/Archive.pm new file mode 100644 index 000000000..3ad4eebae --- /dev/null +++ b/lib/MetaCPAN/Model/Archive.pm @@ -0,0 +1,174 @@ +package MetaCPAN::Model::Archive; + +use v5.10; +use Moose; +use MooseX::StrictConstructor; +use MetaCPAN::Types::TypeTiny qw( AbsPath ArrayRef Bool InstanceOf Str ); + +use Archive::Any (); +use Carp qw( croak ); +use Digest::file qw( digest_file_hex ); +use Path::Tiny qw( path ); + +=head1 NAME + +MetaCPAN::Model::Archive - Inspect and extract archive files + +=head1 SYNOPSIS + + use MetaCPAN::Model::Archive; + + my $archive = MetaCPAN::Model::Archive->new( file => $some_file ); + my $files = $archive->files; + my $extraction_dir = $archive->extract; + +=head1 DESCRIPTION + +This class manages getting information about and extraction of archive +files (tarballs, zipfiles, etc...) and their extraction directories. + +The object is read-only and will only extract once. If you alter the +extraction directory and want a fresh one, make a new object. + +The Archive will clean up its extraction directory upon destruction. + +=head1 ATTRIBUTES + +=head3 archive + +I + +The file to be extracted. It will be returned as a Path::Tiny +object. + +=cut + +has file => ( + is => 'ro', + isa => AbsPath, + coerce => 1, + required => 1, +); + +has _extractor => ( + is => 'ro', + isa => InstanceOf ['Archive::Any'], + handles => [ qw( + is_impolite + is_naughty + ) ], + init_arg => undef, + lazy => 1, + default => sub { + my $self = shift; + croak $self->file . ' does not exist' unless -e $self->file; + return Archive::Any->new( $self->file ); + }, +); + +# MD5 digest for the archive file +has file_digest_md5 => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $self = shift; + digest_file_hex( $self->file, 'MD5' ); + }, +); + +# SHA256 digest for the archive file +has file_digest_sha256 => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $self = shift; + digest_file_hex( $self->file, 'SHA-256' ); + }, +); + +# Holding the File::Temp::Dir object here is necessary to keep it +# alive until the object is destroyed. +has _tempdir => ( + is => 'ro', + isa => AbsPath, + init_arg => undef, + lazy => 1, + default => sub { + + my $scratch_disk = '/mnt/scratch_disk'; + return -d $scratch_disk + ? Path::Tiny->tempdir('/mnt/scratch_disk/tempXXXXX') + : Path::Tiny->tempdir; + }, +); + +has extract_dir => ( + is => 'ro', + isa => AbsPath, + lazy => 1, + coerce => 1, + default => sub { + my $self = shift; + return path( $self->_tempdir ); + }, +); + +has _has_extracted => ( + is => 'ro', + isa => Bool, + init_arg => undef, + default => 0, + writer => '_set_has_extracted', +); + +=head1 METHODS + +=head3 files + + my $files = $archive->files; + +A list of the files in the archive as an array ref. + +=cut + +# A cheap way to cache the result. +has files => ( + is => 'ro', + isa => ArrayRef, + init_arg => undef, + lazy => 1, + default => sub { + my $self = shift; + return [ $self->_extractor->files ]; + }, +); + +=head3 extract + + my $extract_dir = $archive->extract; + +Extract the archive into a temp directory. The directory will be a +L object. + +Only the first call to extract will perform the extraction. After +that it will just return the extraction directory. If you want to +re-extract the archive, create a new object. + +The extraction directory will be cleaned up when the object is destroyed. + +=cut + +sub extract { + my $self = shift; + + return $self->extract_dir if $self->_has_extracted; + + $self->_extractor->extract( $self->extract_dir ); + $self->_set_has_extracted(1); + + return $self->extract_dir; +} + +1; diff --git a/lib/MetaCPAN/Model/ESWrapper.pm b/lib/MetaCPAN/Model/ESWrapper.pm new file mode 100644 index 000000000..cd990c897 --- /dev/null +++ b/lib/MetaCPAN/Model/ESWrapper.pm @@ -0,0 +1,61 @@ +package MetaCPAN::Model::ESWrapper; +use strict; +use warnings; + +use MetaCPAN::Types::TypeTiny qw( ES ); + +sub new { + my ( $class, $es ) = @_; + if ( $es->api_version le '6_0' ) { + return $es; + } + return bless { es => ES->assert_coerce($es) }, $class; +} + +sub DESTROY { } + +sub AUTOLOAD { + my $sub = our $AUTOLOAD =~ s/.*:://r; + my $self = shift; + $self->{es}->$sub(@_); +} + +sub _args { + my $self = shift; + if ( @_ == 1 ) { + return ( $self, %{ $_[0] } ); + } + return ( $self, @_ ); +} + +sub count { + my ( $self, %args ) = &_args; + delete $args{type}; + $self->{es}->count(%args); +} + +sub get { + my ( $self, %args ) = &_args; + delete $args{type}; + $self->{es}->get(%args); +} + +sub delete { + my ( $self, %args ) = &_args; + delete $args{type}; + $self->{es}->delete(%args); +} + +sub search { + my ( $self, %args ) = &_args; + delete $args{type}; + $self->{es}->search(%args); +} + +sub scroll_helper { + my ( $self, %args ) = &_args; + delete $args{type}; + $self->{es}->scroll_helper(%args); +} + +1; diff --git a/lib/MetaCPAN/Model/Email/PAUSE.pm b/lib/MetaCPAN/Model/Email/PAUSE.pm new file mode 100644 index 000000000..b022dcf0d --- /dev/null +++ b/lib/MetaCPAN/Model/Email/PAUSE.pm @@ -0,0 +1,90 @@ +package MetaCPAN::Model::Email::PAUSE; + +use MetaCPAN::Moose; + +use Email::Sender::Simple qw( sendmail ); +use Email::Sender::Transport::SMTP (); +use Email::Simple (); +use Encode (); +use MetaCPAN::Types::TypeTiny qw( Object Uri ); +use Try::Tiny qw( catch try ); + +with('MetaCPAN::Role::HasConfig'); + +has _author => ( + is => 'ro', + isa => Object, + init_arg => 'author', + required => 1, +); + +has _url => ( + is => 'ro', + isa => Uri, + init_arg => 'url', + required => 1, +); + +sub send { + my $self = shift; + + my $email = Email::Simple->create( + header => [ + 'Content-Type' => 'text/plain; charset=utf-8', + To => $self->_author->{email}->[0], + From => 'notifications@metacpan.org', + Subject => 'Connect MetaCPAN with your PAUSE account', + 'MIME-Version' => '1.0', + ], + body => $self->_email_body, + ); + + my $config = $self->config->{smtp}; + my $transport = Email::Sender::Transport::SMTP->new( { + debug => 1, + host => $config->{host}, + port => $config->{port}, + sasl_username => $config->{username}, + sasl_password => $config->{password}, + ssl => 1, + } ); + + my $success = 0; + try { + $success = sendmail( $email, { transport => $transport } ); + } + catch { + warn 'Could not send message: ' . $_; + }; + + return $success; +} + +sub _email_body { + my $self = shift; + my $name = $self->_author->name; + my $uri = $self->_url; + + my $body = < ( + is => 'ro', + isa => InstanceOf ['MetaCPAN::Model::Archive'], + lazy => 1, + builder => '_build_archive', +); + +has dependencies => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + builder => '_build_dependencies', +); + +has distinfo => ( + is => 'ro', + isa => InstanceOf ['CPAN::DistnameInfo'], + handles => { + maturity => 'maturity', + author => 'cpanid', + name => 'distvname', + distribution => 'dist', + filename => 'filename', + }, + lazy => 1, + default => sub { + my $self = shift; + return CPAN::DistnameInfo->new( $self->file ); + }, +); + +has document => ( + is => 'ro', + isa => InstanceOf ['MetaCPAN::Document::Release'], + lazy => 1, + builder => '_build_document', +); + +has file => ( + is => 'ro', + isa => AbsPath, + required => 1, + coerce => 1, +); + +has files => ( + is => 'ro', + isa => ArrayRef, + init_arg => undef, + lazy => 1, + builder => '_build_files', +); + +has date => ( + is => 'ro', + isa => InstanceOf ['DateTime'], + lazy => 1, + default => sub { + my $self = shift; + return DateTime->from_epoch( epoch => $self->file->stat->mtime ); + }, +); + +has model => ( is => 'ro' ); + +has metadata => ( + is => 'ro', + isa => InstanceOf ['CPAN::Meta'], + lazy => 1, + builder => '_build_metadata', +); + +has modules => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + default => sub { + my $self = shift; + if ( keys %{ $self->metadata->provides } ) { + return $self->_modules_from_meta; + } + else { + return $self->_modules_from_files; + } + }, +); + +has version => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + return fix_version( shift->distinfo->version ); + }, +); + +has status => ( + is => 'ro', + isa => Str, +); + +has es => ( is => 'ro' ); +has bulk => ( is => 'ro' ); + +=head2 run + +Try to fix some ordering issues, which are causing deep recursion. There's +probably a much cleaner way to do this. + +=cut + +sub run { + my $self = shift; + $self->document; + $self->document->_set_changes_file( + $self->get_changes_file( $self->files ) ); + $self->set_main_module( $self->modules, $self->document ); +} + +sub _build_archive { + my $self = shift; + + log_info { 'Processing ', $self->file }; + + my $archive = MetaCPAN::Model::Archive->new( file => $self->file ); + + log_error { $self->file, ' is being impolite' } if $archive->is_impolite; + + log_error { $self->file, ' is being naughty' } if $archive->is_naughty; + + return $archive; +} + +sub _build_dependencies { + my $self = shift; + my $meta = $self->metadata; + + log_debug {'Gathering dependencies'}; + + my @dependencies; + if ( my $prereqs = $meta->prereqs ) { + while ( my ( $phase, $data ) = each %$prereqs ) { + while ( my ( $relationship, $v ) = each %$data ) { + while ( my ( $module, $version ) = each %$v ) { + push( + @dependencies, + Dlog_trace {"adding dependency $_"} +{ + phase => $phase, + relationship => $relationship, + module => $module, + version => $version, + } + ); + } + } + } + } + + log_debug { 'Found ', scalar @dependencies, ' dependencies' }; + + return \@dependencies; +} + +sub _build_document { + my $self = shift; + + my $st = $self->file->stat; + my $stat = { map { $_ => $st->$_ } qw(mode size mtime) }; + + my $meta = $self->metadata; + my $dependencies = $self->dependencies; + + my $document = DlogS_trace {"adding release $_"} +{ + abstract => MetaCPAN::Util::strip_pod( $meta->abstract ), + archive => $self->filename, + author => $self->author, + checksum_md5 => $self->archive->file_digest_md5, + checksum_sha256 => $self->archive->file_digest_sha256, + date => $self->date . q{}, + dependency => $dependencies, + distribution => $self->distribution, + + # CPAN::Meta->license *must* be called in list context + # (and *may* return multiple strings). + license => [ $meta->license ], + maturity => $self->maturity, + metadata => $meta, + name => $self->name, + provides => [], + stat => $stat, + status => $self->status, + +# Call in scalar context to make sure we only get one value (building a hash). + ( map { ( $_ => scalar $meta->$_ ) } qw( version resources ) ), + }; + + delete $document->{abstract} + if ( $document->{abstract} eq 'unknown' + || $document->{abstract} eq 'null' ); + + $document + = $self->model->doc('release')->put( $document, { refresh => true } ); + + # create distribution if doesn't exist + $self->es->update( + es_doc_path('distribution'), + id => $self->distribution, + body => { + doc => { + name => $self->distribution, + }, + doc_as_upsert => true, + }, + ); + + return $document; +} + +sub set_main_module { + my $self = shift; + my ( $mod, $release ) = @_; + + # Only select modules (files) that have modules (packages). + my @modules = grep { scalar @{ $_->module } } @$mod; + + return unless @modules; + + my $dist2module = $release->distribution; + $dist2module =~ s{-}{::}g; + + if ( scalar @modules == 1 ) { + + # there is only one module and it will become the main_module + $release->_set_main_module( $modules[0]->module->[0]->name ); + return; + } + + foreach my $file (@modules) { + + # the module has the exact name as the ditribution + if ( $file->module->[0]->name eq $dist2module ) { + $release->_set_main_module( $file->module->[0]->name ); + return; + } + } + + # the distribution has modules on different levels + # the main_module is the first one with the minimum level + # or if they are on the same level, the one with the shortest name + my @sorted_modules = sort { + $a->level <=> $b->level + || length $a->module->[0]->name <=> length $b->module->[0]->name + } @modules; + $release->_set_main_module( $sorted_modules[0]->module->[0]->name ); + +} + +my @changes_files = qw( + CHANGELOG + ChangeLog + Changelog + CHANGES + Changes + NEWS +); +my @exclude_dirs = qw( + corpus + fatlib + inc + local + perl5 + share + t + xt +); + +# this should match the same set of files as MetaCPAN::Query::File->interesting_files +my ($changes_match) = map qr/^(?:$_)$/, join '|', + ( map quotemeta, @changes_files, map "$_.md", @changes_files ), + ( + "(?:(?!" + . join( '|', map "$_/", @exclude_dirs ) + . ").*/)?(?:" + . join( + '|', map quotemeta, map +( "$_.pm", "$_.pod" ), @changes_files + ) + . ')' + ); + +sub get_changes_file { + my $self = shift; + my @files = @{ $_[0] }; + if ( $files[0]->distribution eq 'perl' ) { + foreach my $file (@files) { + if ( $file->name eq 'perldelta.pod' ) { + return $file->path; + } + } + } + + # prioritize files in the top level but otherwise alphabetical + @files = sort { $a->level <=> $b->level || $a->path cmp $b->path } @files; + + foreach my $file (@files) { + return $file->path if $file->path =~ $changes_match; + } +} + +sub _build_files { + my $self = shift; + + my @files; + log_debug { 'Indexing ', scalar @{ $self->archive->files }, ' files' }; + my $file_set = $self->model->doc('file'); + + my $extract_dir = $self->extract; + File::Find::find( + sub { + my $child = path($File::Find::name); + return if $self->_is_broken_file($File::Find::name); + my $relative = $child->relative($extract_dir); + my $stat = do { + my $s = $child->stat; + +{ map { $_ => $s->$_ } qw(mode size mtime) }; + }; + return if ( $relative eq q{.} ); + ( my $fpath = "$relative" ) =~ s/^.*?\///; + my $filename = $fpath; + $child->is_dir + ? $filename =~ s/^(.*\/)?(.+?)\/?$/$2/ + : $filename =~ s/.*\///; + $fpath = q{} if $relative !~ /\// && !$self->archive->is_impolite; + + my $file = $file_set->new_document( + Dlog_trace {"adding file $_"} +{ + author => $self->author, + binary => -B $child ? true : false, + content => $child->is_dir ? \"" + : \( scalar $child->slurp ), + date => $self->date, + directory => $child->is_dir ? true : false, + distribution => $self->distribution, + indexed => $self->metadata->should_index_file($fpath) + ? true + : false, + local_path => $child, + maturity => $self->maturity, + metadata => $self->metadata, + name => $filename, + path => $fpath, + release => $self->name, + download_url => $self->document->download_url, + stat => $stat, + status => $self->status, + version => $self->version, + } + ); + + $self->bulk->put($file); + push( @files, $file ); + }, + $extract_dir + ); + + $self->bulk->commit; + + return \@files; +} + +my @always_no_index_dirs = ( + + # Always ignore the same dirs as PAUSE (lib/PAUSE/dist.pm): + ## skip "t" - libraries in ./t are test libraries! + ## skip "xt" - libraries in ./xt are author test libraries! + ## skip "inc" - libraries in ./inc are usually install libraries + ## skip "local" - somebody shipped his carton setup! + ## skip 'perl5" - somebody shipped her local::lib! + ## skip 'fatlib' - somebody shipped their fatpack lib! + qw( t xt inc local perl5 fatlib ), + + # and add a few more + qw( example blib examples eg ), +); + +sub _build_metadata { + my $self = shift; + + my $extract_dir = $self->extract; + + return $self->_load_meta_file || CPAN::Meta->new( { + license => 'unknown', + name => $self->distribution, + no_index => { directory => [@always_no_index_dirs] }, + version => $self->version || 0, + } ); +} + +sub _load_meta_file { + my $self = shift; + + my $extract_dir = $self->extract; + + my @files; + for (qw{*/META.json */META.yml */META.yaml META.json META.yml META.yaml}) + { + + # scalar context globbing (without exhausting results) produces + # confusing results (which caused existsing */META.json files to + # get skipped). using list context seems more reliable. + my ($path) = <$extract_dir/$_>; + push( @files, $path ) if ( $path && -e $path ); + } + return unless (@files); + + my $last; + for my $file (@files) { + try { + $last = CPAN::Meta->load_file($file); + } + catch { + log_warn {"META file ($file) could not be loaded: $_"}; + }; + if ($last) { + last; + } + } + if ($last) { + push( @{ $last->{no_index}->{directory} }, @always_no_index_dirs ); + return $last; + } + + log_warn {'No META files could be loaded'}; +} + +sub extract { + my $self = shift; + + log_debug {'Extracting archive to filesystem'}; + return $self->archive->extract; +} + +sub _is_broken_file { + my $self = shift; + my $filename = shift; + + return 1 if ( -p $filename || !-e $filename ); + + if ( -l $filename ) { + my $syml = readlink $filename; + return 1 if ( !-e $filename && !-l $filename ); + } + return 0; +} + +sub _modules_from_meta { + my $self = shift; + + my @modules; + + my $provides = $self->metadata->provides; + my $files = $self->files; + my %files = map +( $_->path => $_ ), @$files; + foreach my $module ( sort keys %$provides ) { + my $data = $provides->{$module}; + my $path = File::Spec->canonpath( $data->{file} ); + + my $file = $files{$path} + or next; + + next unless $file->indexed; + + $file->add_module( { + name => $module, + version => $data->{version}, + indexed => true, + } ); + push( @modules, $file ); + } + + return \@modules; +} + +sub _modules_from_files { + my $self = shift; + + my @modules; + + my @perl_files = grep { $_->name =~ m{(?:\.pm|\.pm\.PL)\z} } + grep { $_->indexed } @{ $self->files }; + foreach my $file (@perl_files) { + if ( $file->name =~ m{\.PL\z} ) { + my $parser = Parse::PMFile->new( $self->metadata->as_struct ); + + # FIXME: Should there be a timeout on this + # (like there is below for Module::Metadata)? + my $info = $parser->parse( $file->local_path ); + next if !$info; + + foreach my $module_name ( keys %{$info} ) { + $file->add_module( { + name => $module_name, + defined $info->{$module_name}->{version} + ? ( version => $info->{$module_name}->{version} ) + : (), + } ); + } + push @modules, $file; + } + else { + eval { + local $SIG{'ALRM'} = sub { + log_error {'Call to Module::Metadata timed out '}; + die; + }; + alarm(50); + my $info; + { + local $SIG{__WARN__} = sub { }; + $info = Module::Metadata->new_from_file( + $file->local_path ); + } + + # Ignore packages that people cannot claim. + # https://github.com/andk/pause/blob/master/lib/PAUSE/pmfile.pm#L236 + # + # Parse::PMFile and PAUSE translate apostrophes to double colons, + # but Module::Metadata does not. + my @packages + = map s{'}{::}gr, + grep { $_ ne 'main' && $_ ne 'DB' } + $info->packages_inside; + + for my $pkg (@packages) { + my $version = $info->version($pkg); + $file->add_module( { + name => $pkg, + defined $version + +# Stringify if it's a version object, otherwise fall back to stupid stringification +# Changes in Module::Metadata were causing inconsistencies in the return value, +# we are just trying to survive. + ? ( + version => ref $version eq 'version' + ? $version->stringify + : ( $version . q{} ) + ) + : () + } ); + } + push( @modules, $file ); + alarm(0); + }; + } + } + + return \@modules; +} + +__PACKAGE__->meta->make_immutable(); +1; diff --git a/lib/MetaCPAN/Model/User/Account.pm b/lib/MetaCPAN/Model/User/Account.pm new file mode 100644 index 000000000..bfb37ce31 --- /dev/null +++ b/lib/MetaCPAN/Model/User/Account.pm @@ -0,0 +1,132 @@ +package MetaCPAN::Model::User::Account; + +use strict; +use warnings; + +use Moose; +use ElasticSearchX::Model::Document; + +use MetaCPAN::Types qw( Identity ); +use MetaCPAN::Types::TypeTiny qw( ArrayRef Dict Str ); +use MetaCPAN::Util qw(true false); + +=head1 PROPERTIES + +=head2 id + +ID of user account. + +=cut + +has id => ( + id => 1, + is => 'ro', +); + +=head2 identity + +Array of L objects. Each identity is a +authentication provider such as Twitter or GitHub. + +=cut + +has identity => ( + is => 'ro', + required => 1, + isa => Identity, + coerce => 1, + traits => ['Array'], + handles => { add_identity => 'push' }, + default => sub { [] }, +); + +=head2 code + +The code attribute is used temporarily when authenticating using OAuth. + +=cut + +has code => ( + is => 'ro', + clearer => 'clear_token', + writer => '_set_code', +); + +=head2 access_token + +Array of access token that allow third-party applications to authenticate +as the user. + +=cut + +has access_token => ( + is => 'ro', + required => 1, + isa => ArrayRef [ Dict [ token => Str, client => Str ] ], + default => sub { [] }, + dynamic => 1, + traits => ['Array'], + handles => { add_access_token => 'push' }, +); + +=head1 METHODS + +=head2 add_identity + +Adds an identity to L. If the identity is a PAUSE account, +the user ID is added to the corresponding L document. + +=cut + +after add_identity => sub { + my ( $self, $identity ) = @_; + if ( $identity->{name} eq 'pause' ) { + my $profile + = $self->index->model->doc('author')->get( $identity->{key} ); + + # Not every user is an author + if ($profile) { + $profile->_set_user( $self->id ); + $profile->put; + } + } +}; + +=head2 has_identity + +=cut + +sub has_identity { + my ( $self, $identity ) = @_; + return scalar grep { $_->name eq $identity } @{ $self->identity }; +} + +=head2 get_identities + +=cut + +sub get_identities { + my ( $self, $identity ) = @_; + return grep { $_->name eq $identity } @{ $self->identity }; +} + +sub remove_identity { + my ( $self, $identity ) = @_; + my $ids = $self->identity; + my ($id) = grep { $_->{name} eq $identity } @$ids; + @$ids = grep { $_->{name} ne $identity } @$ids; + + if ( $identity eq 'pause' ) { + my $profile = $self->index->model->doc('author')->get( $id->{key} ); + + if ( $profile && $profile->user eq $self->id ) { + $profile->_clear_user; + $profile->put; + } + } + + return $id; +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Model/User/Account/Set.pm b/lib/MetaCPAN/Model/User/Account/Set.pm new file mode 100644 index 000000000..6891aacf1 --- /dev/null +++ b/lib/MetaCPAN/Model/User/Account/Set.pm @@ -0,0 +1,56 @@ +package MetaCPAN::Model::User::Account::Set; + +use Moose; +extends 'ElasticSearchX::Model::Document::Set'; + +=head1 SET METHODS + +=head2 find + + $type->find({ name => "github", key => 123455 }); + +Find an account based on its identity. + +=cut + +sub find { + my ( $self, $p ) = @_; + return $self->query( { + bool => { + must => [ + { term => { 'identity.name' => $p->{name} } }, + { term => { 'identity.key' => $p->{key} } }, + ], + } + } )->first; +} + +=head2 find_code + + $type->find_code($code); + +Find account by C<$code>. See L. + +=cut + +sub find_code { + my ( $self, $token ) = @_; + return $self->query( { term => { code => $token } } )->first; +} + +=head2 find_token + + $type->find_token($access_token); + +Find account by C<$access_token>. See L. + +=cut + +sub find_token { + my ( $self, $token ) = @_; + return $self->query( { term => { 'access_token.token' => $token } } ) + ->first; +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Model/User/Identity.pm b/lib/MetaCPAN/Model/User/Identity.pm new file mode 100644 index 000000000..6236b31a2 --- /dev/null +++ b/lib/MetaCPAN/Model/User/Identity.pm @@ -0,0 +1,25 @@ +package MetaCPAN::Model::User::Identity; + +use strict; +use warnings; + +use Moose; +use ElasticSearchX::Model::Document; +use MetaCPAN::Types::TypeTiny qw( HashRef ); + +has name => ( + is => 'ro', + required => 1, +); + +has key => ( is => 'ro' ); + +has extra => ( + is => 'ro', + isa => HashRef, + source_only => 1, + dynamic => 1, +); + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Model/User/Session.pm b/lib/MetaCPAN/Model/User/Session.pm new file mode 100644 index 000000000..377068a55 --- /dev/null +++ b/lib/MetaCPAN/Model/User/Session.pm @@ -0,0 +1,10 @@ +package MetaCPAN::Model::User::Session; + +use strict; +use warnings; + +use Moose; +use ElasticSearchX::Model::Document; + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Pod/Renderer.pm b/lib/MetaCPAN/Pod/Renderer.pm new file mode 100644 index 000000000..6cb0591dd --- /dev/null +++ b/lib/MetaCPAN/Pod/Renderer.pm @@ -0,0 +1,100 @@ +package MetaCPAN::Pod::Renderer; + +use MetaCPAN::Moose; + +use MetaCPAN::Pod::HTML; +use MetaCPAN::Types::TypeTiny qw( Uri ); +use Pod::Markdown (); +use Pod::Simple::JustPod (); +use Pod::Text (); + +has perldoc_url_prefix => ( + is => 'ro', + isa => Uri, + coerce => 1, + default => 'https://metacpan.org/pod/', + writer => '_set_perldoc_url_prefix', +); + +has nix_X_codes => ( is => 'ro' ); + +has no_errata_section => ( + is => 'ro', + default => 1, +); + +has link_mappings => ( is => 'ro' ); + +sub markdown_renderer { + my $self = shift; + return Pod::Markdown->new( + perldoc_url_prefix => $self->perldoc_url_prefix ); +} + +sub pod_renderer { + my $self = shift; + return Pod::Simple::JustPod->new; +} + +sub text_renderer { + my $self = shift; + return Pod::Text->new( sentence => 0, width => 78 ); +} + +sub html_renderer { + my $self = shift; + + my $parser = MetaCPAN::Pod::HTML->new; + + $parser->html_footer(''); + $parser->html_header(''); + $parser->index(1); + $parser->no_errata_section( $self->no_errata_section ); + $parser->perldoc_url_prefix( $self->perldoc_url_prefix ); + $parser->link_mappings( $self->link_mappings ); + + return $parser; +} + +sub to_markdown { + my $self = shift; + my $source = shift; + + return $self->_generic_render( $self->markdown_renderer, $source ); +} + +sub to_text { + my $self = shift; + my $source = shift; + + return $self->_generic_render( $self->text_renderer, $source ); +} + +sub to_html { + my $self = shift; + my $source = shift; + + return $self->_generic_render( $self->html_renderer, $source ); +} + +sub to_pod { + my $self = shift; + my $source = shift; + + return $self->_generic_render( $self->pod_renderer, $source ); +} + +sub _generic_render { + my $self = shift; + my $renderer = shift; + my $source = shift; + my $output = q{}; + + $renderer->output_string( \$output ); + $renderer->parse_string_document($source); + + return $output; +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Pod/XHTML.pm b/lib/MetaCPAN/Pod/XHTML.pm deleted file mode 100644 index c3c8f87a7..000000000 --- a/lib/MetaCPAN/Pod/XHTML.pm +++ /dev/null @@ -1,59 +0,0 @@ -package MetaCPAN::Pod::XHTML; - -use Moose; - -extends 'Pod::Simple::XHTML'; - -use Modern::Perl; -use Data::Dump qw( dump ); -use HTML::Entities; -use IO::File; -use Path::Class::File; - -sub start_L { - my ( $self, $flags ) = @_; - my ( $type, $to, $section ) = @{$flags}{ 'type', 'to', 'section' }; - - #print "$type $to $section\n" if $section; - - my $url - = $type eq 'url' ? $to - : $type eq 'pod' ? $self->resolve_pod_page_link( $to, $section ) - : $type eq 'man' ? $self->resolve_man_page_link( $to, $section ) - : undef; - - my $class = ( $type eq 'pod' ) ? ' class="moduleLink"' : ''; - - $self->{'scratch'} .= qq[]; -} - -sub start_Verbatim { - -} - -sub end_Verbatim { - - $_[0]{'scratch'} = '
' . $_[0]{'scratch'} . '
'; - $_[0]->emit; - -} - -1; - -=pod - -=head2 start_L - -Add the "moduleLink" class to any hrefs which link directly to module docs. - -=head2 start_Verbatim - -Override default behaviour by doing nothing. - -=head2 end_Verbatim - -Wrap code snippets in
 tags for easier syntax highlighting.
-
-=cut
-
-
diff --git a/lib/MetaCPAN/Query.pm b/lib/MetaCPAN/Query.pm
new file mode 100644
index 000000000..d9160f077
--- /dev/null
+++ b/lib/MetaCPAN/Query.pm
@@ -0,0 +1,52 @@
+package MetaCPAN::Query;
+use Moose;
+
+use Module::Runtime           qw( require_module );
+use Module::Pluggable::Object ();
+use MetaCPAN::Types::TypeTiny qw( ES );
+
+has es => (
+    is       => 'ro',
+    required => 1,
+    isa      => ES,
+    coerce   => 1,
+);
+
+my @plugins = Module::Pluggable::Object->new(
+    search_path => [__PACKAGE__],
+    max_depth   => 3,
+    require     => 0,
+)->plugins;
+
+for my $class (@plugins) {
+    require_module($class);
+    my $name = $class->can('name') && $class->name
+        or next;
+
+    my $in  = "_in_$name";
+    my $gen = "_gen_$name";
+
+    has $in => (
+        is       => 'ro',
+        init_arg => $name,
+        weak_ref => 1,
+    );
+
+    has $gen => (
+        is       => 'ro',
+        init_arg => undef,
+        lazy     => 1,
+        default  => sub {
+            my $self = shift;
+            $class->new(
+                es    => $self->es,
+                query => $self,
+            );
+        },
+    );
+
+    no strict 'refs';
+    *$name = sub { $_[0]->$in // $_[0]->$gen };
+}
+
+1;
diff --git a/lib/MetaCPAN/Query/Author.pm b/lib/MetaCPAN/Query/Author.pm
new file mode 100644
index 000000000..f3323e913
--- /dev/null
+++ b/lib/MetaCPAN/Query/Author.pm
@@ -0,0 +1,132 @@
+package MetaCPAN::Query::Author;
+
+use MetaCPAN::Moose;
+
+use MetaCPAN::ESConfig qw( es_doc_path );
+use MetaCPAN::Util     qw( MAX_RESULT_WINDOW hit_total );
+use Ref::Util          qw( is_arrayref );
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub by_ids {
+    my ( $self, $ids ) = @_;
+
+    map {uc} @{$ids};
+
+    my $body = {
+        query => { ids => { values => $ids } },
+        size  => scalar @{$ids},
+    };
+
+    my $authors = $self->es->search( es_doc_path('author'), body => $body, );
+
+    my @authors = map $_->{_source}, @{ $authors->{hits}{hits} };
+
+    return {
+        authors => \@authors,
+        took    => $authors->{took},
+        total   => hit_total($authors),
+    };
+}
+
+sub by_user {
+    my ( $self, $users ) = @_;
+    $users = [$users] unless is_arrayref($users);
+
+    my $authors = $self->es->search(
+        es_doc_path('author'),
+        body => {
+            query => { terms => { user => $users } },
+            size  => 500,
+        }
+    );
+
+    my @authors = map $_->{_source}, @{ $authors->{hits}{hits} };
+
+    return {
+        authors => \@authors,
+        took    => $authors->{took},
+        total   => hit_total($authors),
+    };
+}
+
+sub search {
+    my ( $self, $query, $from ) = @_;
+
+    $from //= 0;
+    my $size = 10;
+
+    if ( $from * $size >= MAX_RESULT_WINDOW ) {
+        return +{
+            authors => [],
+            took    => 0,
+            total   => 0,
+        };
+    }
+
+    my $body = {
+        query => {
+            bool => {
+                should => [
+                    {
+                        match => {
+                            'name.analyzed' =>
+                                { query => $query, operator => 'AND' }
+                        }
+                    },
+                    {
+                        match => {
+                            'asciiname.analyzed' =>
+                                { query => $query, operator => 'AND' }
+                        }
+                    },
+                    { match => { 'pauseid'    => uc($query) } },
+                    { match => { 'profile.id' => lc($query) } },
+                ],
+            }
+        },
+        size => $size,
+        from => $from || 0,
+    };
+
+    my $ret = $self->es->search( es_doc_path('author'), body => $body, );
+
+    my @authors = map { +{ %{ $_->{_source} }, id => $_->{_id} } }
+        @{ $ret->{hits}{hits} };
+
+    return +{
+        authors => \@authors,
+        took    => $ret->{took},
+        total   => hit_total($ret),
+    };
+}
+
+sub prefix_search {
+    my ( $self, $query, $opts ) = @_;
+    my $size = $opts->{size} // 500;
+    my $from = $opts->{from} // 0;
+
+    my $body = {
+        query => {
+            prefix => {
+                pauseid => $query,
+            },
+        },
+        size => $size,
+        from => $from,
+    };
+
+    my $ret = $self->es->search( es_doc_path('author'), body => $body, );
+
+    my @authors = map { +{ %{ $_->{_source} }, id => $_->{_id} } }
+        @{ $ret->{hits}{hits} };
+
+    return +{
+        authors => \@authors,
+        took    => $ret->{took},
+        total   => hit_total($ret),
+    };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/CVE.pm b/lib/MetaCPAN/Query/CVE.pm
new file mode 100644
index 000000000..20a58d328
--- /dev/null
+++ b/lib/MetaCPAN/Query/CVE.pm
@@ -0,0 +1,63 @@
+package MetaCPAN::Query::CVE;
+
+use MetaCPAN::Moose;
+
+use MetaCPAN::ESConfig qw( es_doc_path );
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub find_cves_by_cpansa {
+    my ( $self, $cpansa_id ) = @_;
+
+    my $query = +{ term => { cpansa_id => $cpansa_id } };
+
+    my $res = $self->es->search(
+        es_doc_path('cve'),
+        body => {
+            query => $query,
+            size  => 999,
+        }
+    );
+
+    return +{ cve => [ map { $_->{_source} } @{ $res->{hits}{hits} } ] };
+}
+
+sub find_cves_by_release {
+    my ( $self, $author, $release ) = @_;
+
+    my $query = +{ match => { releases => "$author/$release" } };
+
+    my $res = $self->es->search(
+        es_doc_path('cve'),
+        body => {
+            query => $query,
+            size  => 999,
+        }
+    );
+
+    return +{ cve => [ map { $_->{_source} } @{ $res->{hits}{hits} } ] };
+}
+
+sub find_cves_by_dist {
+    my ( $self, $dist, $version ) = @_;
+
+    my $query = +{
+        match => {
+            dist => $dist,
+            ( defined $version ? ( versions => $version ) : () ),
+        }
+    };
+
+    my $res = $self->es->search(
+        es_doc_path('cve'),
+        body => {
+            query => $query,
+            size  => 999,
+        }
+    );
+
+    return +{ cve => [ map { $_->{_source} } @{ $res->{hits}{hits} } ] };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/Contributor.pm b/lib/MetaCPAN/Query/Contributor.pm
new file mode 100644
index 000000000..d0b1859b9
--- /dev/null
+++ b/lib/MetaCPAN/Query/Contributor.pm
@@ -0,0 +1,67 @@
+package MetaCPAN::Query::Contributor;
+
+use MetaCPAN::Moose;
+
+use MetaCPAN::ESConfig qw( es_doc_path );
+use MetaCPAN::Util     qw(hit_total);
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub find_release_contributors {
+    my ( $self, $author, $name ) = @_;
+
+    my $query = +{
+        bool => {
+            must => [
+                { term   => { release_author => $author } },
+                { term   => { release_name   => $name } },
+                { exists => { field          => 'pauseid' } },
+            ]
+        }
+    };
+
+    my $res = $self->es->search(
+        es_doc_path('contributor'),
+        body => {
+            query   => $query,
+            size    => 999,
+            _source => [ qw(
+                distribution
+                pauseid
+                release_author
+                release_name
+            ) ],
+        }
+    );
+    hit_total($res) or return {};
+
+    return +{
+        contributors => [ map { $_->{_source} } @{ $res->{hits}{hits} } ] };
+}
+
+sub find_author_contributions {
+    my ( $self, $pauseid ) = @_;
+
+    my $query = +{ term => { pauseid => $pauseid } };
+
+    my $res = $self->es->search(
+        es_doc_path('contributor'),
+        body => {
+            query   => $query,
+            size    => 999,
+            _source => [ qw(
+                distribution
+                pauseid
+                release_author
+                release_name
+            ) ],
+        }
+    );
+    hit_total($res) or return {};
+
+    return +{
+        contributors => [ map { $_->{_source} } @{ $res->{hits}{hits} } ] };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/Cover.pm b/lib/MetaCPAN/Query/Cover.pm
new file mode 100644
index 000000000..d524ea5c9
--- /dev/null
+++ b/lib/MetaCPAN/Query/Cover.pm
@@ -0,0 +1,31 @@
+package MetaCPAN::Query::Cover;
+
+use MetaCPAN::Moose;
+
+use MetaCPAN::ESConfig qw( es_doc_path );
+use MetaCPAN::Util     qw(hit_total);
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub find_release_coverage {
+    my ( $self, $release ) = @_;
+
+    my $query = +{ term => { release => $release } };
+
+    my $res = $self->es->search(
+        es_doc_path('cover'),
+        body => {
+            query => $query,
+            size  => 999,
+        }
+    );
+    hit_total($res) or return {};
+
+    return +{
+        %{ $res->{hits}{hits}[0]{_source} },
+        url => "http://cpancover.com/latest/$release/index.html",
+    };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/Distribution.pm b/lib/MetaCPAN/Query/Distribution.pm
new file mode 100644
index 000000000..71140d573
--- /dev/null
+++ b/lib/MetaCPAN/Query/Distribution.pm
@@ -0,0 +1,72 @@
+package MetaCPAN::Query::Distribution;
+
+use MetaCPAN::Moose;
+
+use MetaCPAN::ESConfig qw( es_doc_path );
+use MetaCPAN::Util     qw(hit_total);
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub rogue_list {
+    return qw(
+        Acme-DependOnEverything
+        Bundle-Everything
+        kurila
+        perl-5.005_02+apache1.3.3+modperl
+        perlbench
+        perl_debug
+        perl_mlb
+        pod2texi
+        spodcxx
+    );
+}
+
+sub get_river_data_by_dist {
+    my ( $self, $dist ) = @_;
+
+    my $query = +{
+        bool => {
+            must => [ { term => { name => $dist } }, ]
+        }
+    };
+
+    my $res = $self->es->search(
+        es_doc_path('distribution'),
+        body => {
+            query => $query,
+            size  => 999,
+        }
+    );
+    hit_total($res) or return {};
+
+    return +{ river => +{ $dist => $res->{hits}{hits}[0]{_source}{river} } };
+}
+
+sub get_river_data_by_dists {
+    my ( $self, $dist ) = @_;
+
+    my $query = +{
+        bool => {
+            must => [ { terms => { name => $dist } }, ]
+        }
+    };
+
+    my $res = $self->es->search(
+        es_doc_path('distribution'),
+        body => {
+            query => $query,
+            size  => 999,
+        }
+    );
+    hit_total($res) or return {};
+
+    return +{
+        river => +{
+            map { $_->{_source}{name} => $_->{_source}{river} }
+                @{ $res->{hits}{hits} }
+        },
+    };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/Favorite.pm b/lib/MetaCPAN/Query/Favorite.pm
new file mode 100644
index 000000000..67d521657
--- /dev/null
+++ b/lib/MetaCPAN/Query/Favorite.pm
@@ -0,0 +1,197 @@
+package MetaCPAN::Query::Favorite;
+
+use MetaCPAN::Moose;
+
+use MetaCPAN::ESConfig qw( es_doc_path );
+use MetaCPAN::Util     qw( MAX_RESULT_WINDOW hit_total );
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub agg_by_distributions {
+    my ( $self, $distributions, $user ) = @_;
+    return {
+        favorites   => {},
+        myfavorites => {},
+        took        => 0,
+        }
+        unless $distributions;
+
+    my $body = {
+        size  => 0,
+        query => {
+            terms => { distribution => $distributions }
+        },
+        aggregations => {
+            favorites => {
+                terms => {
+                    field => 'distribution',
+                    size  => scalar @{$distributions},
+                },
+            },
+            $user
+            ? (
+                myfavorites => {
+                    filter       => { term => { user => $user } },
+                    aggregations => {
+                        entries => {
+                            terms => { field => 'distribution' }
+                        }
+                    }
+                }
+                )
+            : (),
+        }
+    };
+
+    my $ret = $self->es->search( es_doc_path('favorite'), body => $body, );
+
+    my %favorites = map { $_->{key} => $_->{doc_count} }
+        @{ $ret->{aggregations}{favorites}{buckets} };
+
+    my %myfavorites;
+    if ($user) {
+        %myfavorites = map { $_->{key} => $_->{doc_count} }
+            @{ $ret->{aggregations}{myfavorites}{entries}{buckets} };
+    }
+
+    return {
+        favorites   => \%favorites,
+        myfavorites => \%myfavorites,
+        took        => $ret->{took},
+    };
+}
+
+sub by_user {
+    my ( $self, $user, $size ) = @_;
+    $size ||= 250;
+
+    my $favs = $self->es->search(
+        es_doc_path('favorite'),
+        body => {
+            query   => { term => { user => $user } },
+            _source => [qw( author date distribution )],
+            sort    => ['distribution'],
+            size    => $size,
+        }
+    );
+    return {} unless hit_total($favs);
+    my $took = $favs->{took};
+
+    my @favs = map { $_->{_source} } @{ $favs->{hits}{hits} };
+
+    # filter out backpan only distributions
+
+    my $no_backpan = $self->es->search(
+        es_doc_path('release'),
+        body => {
+            query => {
+                bool => {
+                    must => [
+                        { terms => { status => [qw( cpan latest )] } },
+                        {
+                            terms => {
+                                distribution =>
+                                    [ map { $_->{distribution} } @favs ]
+                            }
+                        },
+                    ]
+                }
+            },
+            _source => ['distribution'],
+            size    => scalar(@favs),
+        }
+    );
+    $took += $no_backpan->{took};
+
+    if ( hit_total($no_backpan) ) {
+        my %has_no_backpan = map { $_->{_source}{distribution} => 1 }
+            @{ $no_backpan->{hits}{hits} };
+
+        @favs = grep { exists $has_no_backpan{ $_->{distribution} } } @favs;
+    }
+
+    return { favorites => \@favs, took => $took };
+}
+
+sub leaderboard {
+    my $self = shift;
+
+    my $body = {
+        size         => 0,
+        query        => { match_all => {} },
+        aggregations => {
+            leaderboard => {
+                terms => {
+                    field => 'distribution',
+                    size  => 100,
+                },
+            },
+            totals => {
+                cardinality => {
+                    field => 'distribution',
+                },
+            },
+        },
+    };
+
+    my $ret = $self->es->search( es_doc_path('favorite'), body => $body, );
+
+    return {
+        leaderboard => $ret->{aggregations}{leaderboard}{buckets},
+        total       => $ret->{aggregations}{totals}{value},
+        took        => $ret->{took},
+    };
+}
+
+sub recent {
+    my ( $self, $page, $size ) = @_;
+    $page //= 1;
+    $size //= 100;
+
+    if ( $page * $size >= MAX_RESULT_WINDOW ) {
+        return +{
+            favorites => [],
+            took      => 0,
+            total     => 0,
+        };
+    }
+
+    my $favs = $self->es->search(
+        es_doc_path('favorite'),
+        body => {
+            size  => $size,
+            from  => ( $page - 1 ) * $size,
+            query => { match_all => {} },
+            sort  => [ { 'date' => { order => 'desc' } } ]
+        }
+    );
+
+    my @favs = map { $_->{_source} } @{ $favs->{hits}{hits} };
+
+    return +{
+        favorites => \@favs,
+        took      => $favs->{took},
+        total     => hit_total($favs),
+    };
+}
+
+sub users_by_distribution {
+    my ( $self, $distribution ) = @_;
+
+    my $favs = $self->es->search(
+        es_doc_path('favorite'),
+        body => {
+            query   => { term => { distribution => $distribution } },
+            _source => ['user'],
+            size    => 1000,
+        }
+    );
+    return {} unless hit_total($favs);
+
+    my @plusser_users = map { $_->{_source}{user} } @{ $favs->{hits}{hits} };
+
+    return { users => \@plusser_users };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/File.pm b/lib/MetaCPAN/Query/File.pm
new file mode 100644
index 000000000..15696c491
--- /dev/null
+++ b/lib/MetaCPAN/Query/File.pm
@@ -0,0 +1,759 @@
+package MetaCPAN::Query::File;
+
+use MetaCPAN::Moose;
+
+use List::Util         qw( max );
+use MetaCPAN::ESConfig qw( es_doc_path );
+use MetaCPAN::Util     qw( hit_total true false );
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub dir {
+    my ( $self, $author, $release, @path ) = @_;
+
+    my $body = {
+        query => {
+            bool => {
+                must => [
+                    { term => { 'level'   => scalar @path } },
+                    { term => { 'author'  => $author } },
+                    { term => { 'release' => $release } },
+                    {
+                        prefix => {
+                            'path' => join( q{/}, @path, q{} )
+                        }
+                    },
+                ]
+            },
+        },
+        size    => 999,
+        _source => [
+            qw(name stat.mtime path stat.size directory slop documentation mime)
+        ],
+    };
+
+    my $data = $self->es->search( {
+        es_doc_path('file'), body => $body,
+    } );
+
+    my $dir = [ map { $_->{_source} } @{ $data->{hits}{hits} } ];
+
+    return { dir => $dir };
+}
+
+sub _doc_files {
+    my @files = @_;
+    my %s;
+    return
+        map +( "$_", "$_.pod", "$_.md", "$_.markdown", "$_.mdown",
+        "$_.mkdn", ),
+        grep !$s{$_}++,
+        map +( $_, uc $_ ),
+        @_;
+}
+
+my %special_files = (
+    changelog => [
+        _doc_files( qw(
+            Changelog
+            ChangeLog
+            Changes
+            News
+        ) ),
+    ],
+    contributing => [
+        _doc_files( qw(
+            Contributing
+            Hacking
+            Development
+        ) ),
+    ],
+    license => [ qw(
+        LICENCE
+        LICENSE
+        Copyright
+        COPYRIGHT
+        Copying
+        COPYING
+        Artistic
+        ARTISTIC
+    ) ],
+    install => [
+        _doc_files( qw(
+            Install
+        ) ),
+    ],
+    dist => [ qw(
+        Build.PL
+        MANIFEST
+        META.json
+        META.yml
+        Makefile.PL
+        alienfile
+        cpanfile
+        prereqs.json
+        prereqs.yml
+        dist.ini
+        minil.toml
+    ) ],
+    security => [
+        _doc_files( qw(
+            Security
+            security
+        ) ),
+        qw(
+            security.txt
+        ),
+    ],
+    other => [
+        _doc_files( qw(
+            Authors
+            Credits
+            FAQ
+            README
+            THANKS
+            ToDo
+            Todo
+        ) ),
+    ],
+);
+my %perl_files = (
+    changelog => [ qw(
+        perldelta.pod
+    ) ],
+    license => [ qw(
+        perlartistic.pod
+        perlgpl.pod
+    ) ],
+    contributing => [ qw(
+        perlhack.pod
+    ) ],
+);
+
+my @shared_path_prefix_examples = qw(
+    example
+    examples
+    Example
+    Examples
+    sample
+    samples
+    demo
+    demos
+);
+
+my %path_files = (
+    example => [
+        qw(
+            eg
+            ex
+        ),
+        @shared_path_prefix_examples,
+    ],
+);
+
+my %prefix_files = ( example => [ @shared_path_prefix_examples, ], );
+
+my %file_to_type;
+my %type_to_regex;
+my %query_parts;
+
+my %sort_order;
+
+for my $type ( keys %special_files ) {
+    my @files      = @{ $special_files{$type} || [] };
+    my @perl_files = @{ $perl_files{$type}    || [] };
+
+    $sort_order{ $files[$_] } = $_ for 0 .. $#files;
+
+    my @root_file     = grep !/\.pod$/, @files;
+    my @non_root_file = grep /\.pod$/,  @files;
+
+    my @parts;
+    if (@root_file) {
+        push @parts,
+            {
+            bool => {
+                must => [
+                    { term  => { level => 0 } },
+                    { terms => { name  => \@root_file } },
+                ],
+                (
+                    @perl_files
+                    ? ( must_not =>
+                            [ { term => { distribution => 'perl' } } ] )
+                    : ()
+                ),
+            }
+            };
+    }
+    if (@non_root_file) {
+        push @parts,
+            {
+            bool => {
+                must => [ { terms => { name => \@non_root_file } } ],
+                (
+                    @perl_files
+                    ? ( must_not =>
+                            [ { term => { distribution => 'perl' } } ] )
+                    : ()
+                ),
+            }
+            };
+    }
+    if (@perl_files) {
+        push @parts,
+            {
+            bool => {
+                must => [
+                    { term  => { distribution => 'perl' } },
+                    { terms => { name         => \@perl_files } },
+                ],
+            }
+            };
+    }
+
+    $file_to_type{$_} = $type for @files, @perl_files;
+    push @{ $query_parts{$type} }, @parts;
+}
+
+for my $type ( keys %prefix_files ) {
+    my @prefixes = @{ $prefix_files{$type} };
+
+    my @parts = map +{ prefix => { 'name' => $_ } }, @prefixes;
+
+    push @{ $query_parts{$type} }, @parts;
+
+    my ($regex) = map qr{(?:\A|/)(?:$_)[^/]*\z}, join '|', @prefixes;
+
+    if ( $type_to_regex{$type} ) {
+        $type_to_regex{$type} = qr{$type_to_regex{$type}|$regex};
+    }
+    else {
+        $type_to_regex{$type} = $regex;
+    }
+}
+
+for my $type ( keys %path_files ) {
+    my @prefixes = @{ $path_files{$type} };
+
+    my @parts = map +{ prefix => { 'path' => "$_/" } }, @prefixes;
+
+    push @{ $query_parts{$type} }, @parts;
+
+    my ($regex) = map qr{\A(?:$_)/}, join '|', @prefixes;
+
+    if ( $type_to_regex{$type} ) {
+        $type_to_regex{$type} = qr{$type_to_regex{$type}|$regex};
+    }
+    else {
+        $type_to_regex{$type} = $regex;
+    }
+}
+
+sub interesting_files {
+    my ( $self, $author, $release, $categories, $options ) = @_;
+
+    $categories = [ sort keys %query_parts ]
+        if !$categories || !@$categories;
+
+    my $return = {
+        files => [],
+        total => 0,
+        took  => 0,
+    };
+
+    my @clauses = map @{ $query_parts{$_} // [] }, @$categories;
+
+    return $return
+        unless @clauses;
+
+    $options->{_source} //= [ qw(
+        author
+        distribution
+        documentation
+        name
+        path
+        pod_lines
+        release
+        status
+    ) ];
+    $options->{size} //= 250;
+
+    my $body = {
+        query => {
+            bool => {
+                must => [
+                    { term => { release   => $release } },
+                    { term => { author    => $author } },
+                    { term => { directory => false } },
+                    { bool => { should    => \@clauses } },
+                ],
+                must_not => [
+                    { prefix => { 'path' => 'corpus/' } },
+                    { prefix => { 'path' => 'fatlib/' } },
+                    { prefix => { 'path' => 'inc/' } },
+                    { prefix => { 'path' => 'local/' } },
+                    { prefix => { 'path' => 'perl5/' } },
+                    { prefix => { 'path' => 'share/' } },
+                    { prefix => { 'path' => 't/' } },
+                    { prefix => { 'path' => 'xt/' } },
+                ],
+            },
+        },
+        %$options,
+    };
+
+    my $data = $self->es->search( {
+        es_doc_path('file'), body => $body,
+    } );
+
+    $return->{took}  = $data->{took};
+    $return->{total} = hit_total($data);
+
+    return $return
+        unless $return->{total};
+
+    my $files = [ map $_->{_source}, @{ $data->{hits}{hits} } ];
+
+    for my $file (@$files) {
+        my $category = $file_to_type{ $file->{name} };
+        if ( !$category ) {
+            for my $type ( keys %type_to_regex ) {
+                if ( $file->{path} =~ $type_to_regex{$type} ) {
+                    $category = $type;
+                    last;
+                }
+            }
+        }
+        $category ||= 'unknown';
+
+        $file->{category} = $category;
+    }
+
+    $return->{files} = $files;
+
+    return $return;
+}
+
+sub files_by_category {
+    my ( $self, $author, $release, $categories, $options ) = @_;
+    my $return = $self->interesting_files( $author, $release, $categories,
+        $options );
+    my $files = delete $return->{files};
+
+    $return->{categories} = { map +( $_ => [] ), @$categories };
+
+    for my $file (@$files) {
+        my $category = $file->{category};
+        push @{ $return->{categories}{$category} }, $file;
+    }
+
+    for my $category (@$categories) {
+        my $files = $return->{categories}{$category};
+        @$files = map $_->[0],
+            sort { $a->[1] <=> $b->[1] || $a->[2] cmp $b->[2] }
+            map [ $_, $sort_order{ $_->{name} } || 9999, $_->{path} ],
+            @$files;
+    }
+    return $return;
+}
+
+sub find_changes_files {
+    my ( $self, $author, $release ) = @_;
+    my $result = $self->files_by_category( $author, $release, ['changelog'],
+        { _source => true } );
+    my ($file) = @{ $result->{categories}{changelog} || [] };
+    return $file;
+}
+
+sub _autocomplete {
+    my ( $self, $query ) = @_;
+
+    my $search_size = 100;
+
+    my $sugg_res = $self->es->search(
+        es_doc_path('file'),
+        body => {
+            suggest => {
+                documentation => {
+                    text       => $query,
+                    completion => {
+                        field => "suggest",
+                        size  => $search_size,
+                    },
+                },
+            }
+        },
+    );
+
+    my %docs;
+    for my $suggest ( @{ $sugg_res->{suggest}{documentation}[0]{options} } ) {
+        $docs{ $suggest->{text} } = max grep {defined}
+            ( $docs{ $suggest->{text} }, $suggest->{score} );
+    }
+
+    my $res = $self->es->search(
+        es_doc_path('file'),
+        body => {
+            query => {
+                bool => {
+                    must => [
+                        { term  => { indexed       => true } },
+                        { term  => { authorized    => true } },
+                        { term  => { status        => 'latest' } },
+                        { terms => { documentation => [ keys %docs ] } },
+                    ],
+                    must_not => [
+                        {
+                            terms => {
+                                distribution => [
+                                    $self->query->distribution->rogue_list
+                                ]
+                            },
+                        },
+                    ],
+                }
+            },
+            _source => [ qw(
+                author
+                date
+                deprecated
+                distribution
+                documentation
+                release
+            ) ],
+            size => $search_size,
+        },
+    );
+
+    my $hits = $res->{hits}{hits};
+
+    my $fav_res
+        = $self->query->favorite->agg_by_distributions(
+        [ map $_->{_source}{distribution}, @$hits ] );
+
+    my $favs = $fav_res->{favorites};
+
+    my %valid = map {
+        my $source = $_->{_source};
+        (
+            $source->{documentation} => {
+                %$source, favorites => $favs->{ $source->{distribution} },
+            }
+        );
+    } @{ $res->{hits}{hits} };
+
+    # remove any exact match, it will be added later
+    my $exact = delete $valid{$query};
+
+    no warnings 'uninitialized';
+    my @sorted = map { $valid{$_} }
+        sort {
+        my $a_data = $valid{$a};
+        my $b_data = $valid{$b};
+               $a_data->{deprecated} <=> $b_data->{deprecated}
+            || $b_data->{favorites}  <=> $a_data->{favorites}
+            || $docs{$b}             <=> $docs{$a}
+            || length($a)            <=> length($b)
+            || $a cmp $b
+        }
+        keys %valid;
+
+    return {
+        took        => $sugg_res->{took} + $res->{took} + $fav_res->{took},
+        suggestions => [ ( $exact ? $exact : () ), @sorted ],
+    };
+}
+
+sub autocomplete {
+    my ( $self, @terms ) = @_;
+    my $data = $self->_autocomplete( join ' ', @terms );
+
+    return {
+        took => $data->{took},
+        hits => {
+            hits => [
+                map {
+                    my $source = $_;
+                    +{
+                        fields => {
+                            map +( $_ => $source->{$_} ), qw(
+                                documentation
+                                release
+                                author
+                                distribution
+                            ),
+                        },
+                    };
+                } @{ $data->{suggestions} }
+            ],
+        },
+    };
+}
+
+sub autocomplete_suggester {
+    my ( $self, @terms ) = @_;
+    my $data = $self->_autocomplete( join ' ', @terms );
+
+    return {
+        took        => $data->{took},
+        suggestions => [
+            map +{
+                author       => $_->{author},
+                date         => $_->{date},
+                deprecated   => $_->{deprecated},
+                distribution => $_->{distribution},
+                name         => $_->{documentation},
+                release      => $_->{release},
+            },
+            @{ $data->{suggestions} }
+        ],
+    };
+}
+
+sub documented_modules {
+    my ( $self, $author, $release ) = @_;
+    my $query = {
+        bool => {
+            must => [
+                { term   => { author  => $author } },
+                { term   => { release => $release } },
+                { exists => { field   => "documentation" } },
+                {
+                    bool => {
+                        should => [
+                            {
+                                bool => {
+                                    must => [
+                                        {
+                                            exists =>
+                                                { field => 'module.name' }
+                                        },
+                                        {
+                                            term =>
+                                                { 'module.indexed' => true }
+                                        },
+                                    ],
+                                }
+                            },
+                            {
+                                bool => {
+                                    must => [
+                                        {
+                                            exists =>
+                                                { field => 'pod.analyzed' }
+                                        },
+                                        { term => { indexed => true } },
+                                    ],
+                                }
+                            },
+                        ],
+                    }
+                },
+            ],
+        },
+    };
+    my $res = $self->es->search(
+        es_doc_path('file'),
+        body => {
+            query   => $query,
+            size    => 999,
+            _source => [qw(name module path documentation distribution)],
+        },
+    );
+
+    return {
+        took  => $res->{took},
+        files => [ map $_->{_source}, @{ $res->{hits}{hits} } ],
+    };
+}
+
+sub find_module {
+    my ( $self, $module, $fields ) = @_;
+
+    my $query = {
+        bool => {
+            must => [
+                { term => { indexed    => true } },
+                { term => { authorized => true } },
+                { term => { status     => 'latest' } },
+                {
+                    bool => {
+                        should => [
+                            { term => { documentation => $module } },
+                            {
+                                nested => {
+                                    path  => "module",
+                                    query => {
+                                        bool => {
+                                            must => [
+                                                {
+                                                    term => { "module.name" =>
+                                                            $module }
+                                                },
+                                                {
+                                                    bool => { should =>
+                                                            [
+                                                            { term =>
+                                                                    { "module.authorized"
+                                                                        => true
+                                                                    } },
+                                                            { exists =>
+                                                                    { field =>
+                                                                        'module.associated_pod'
+                                                                    } },
+                                                            ],
+                                                    }
+                                                },
+                                            ],
+                                        },
+                                    },
+                                }
+                            },
+                        ]
+                    }
+                },
+            ],
+        },
+    };
+
+    my $res = $self->es->search(
+        es_doc_path('file'),
+        search_type => 'dfs_query_then_fetch',
+        body        => {
+            query => $query,
+            sort  => [
+                '_score',
+                { 'version_numified' => { order => 'desc' } },
+                { 'date'             => { order => 'desc' } },
+                { 'mime'             => { order => 'asc' } },
+                { 'stat.mtime'       => { order => 'desc' } }
+            ],
+            _source => [
+                qw( documentation module.indexed module.authoried module.name )
+            ],
+            size => 100,
+        },
+    );
+
+    my @candidates = @{ $res->{hits}{hits} };
+
+    my ($file) = grep {
+        grep { $_->{indexed} && $_->{authorized} && $_->{name} eq $module }
+            @{ $_->{module} || [] }
+    } grep { !$_->{documentation} || $_->{documentation} eq $module }
+        @candidates;
+
+    $file ||= shift @candidates;
+    return undef
+        if !$file;
+    return $self->es->get_source(
+        es_doc_path('file'),
+        id => $file->{_id},
+        ( $fields ? ( _source => $fields ) : () ),
+    );
+}
+
+sub find_pod {
+    my ( $self, $name ) = @_;
+    my $file = $self->find_module($name);
+    return $file
+        unless $file;
+    my ($module)
+        = grep { $_->{indexed} && $_->{authorized} && $_->{name} eq $name }
+        @{ $file->{module} || [] };
+    if ( $module && ( my $pod = $module->{associated_pod} ) ) {
+        my ( $author, $release, @path ) = split( /\//, $pod );
+        my $query = {
+            bool => {
+                must => [
+                    { term => { author  => $author } },
+                    { term => { release => $release } },
+                    { term => { path    => join( '/', @path ) } },
+                ],
+            },
+        };
+        my $pod_file = $self->es->search(
+            es_doc_path('file'),
+            body => {
+                query => $query,
+            },
+        );
+        return $pod_file->{hits}{hits}[0]->{_source};
+    }
+    else {
+        return $file;
+    }
+}
+
+sub history {
+    my ( $self, $type, $name, $path, $opts ) = @_;
+
+    $opts ||= {};
+    if ( ref $path ) {
+        $path = join '/', @$path;
+    }
+
+    my $source = $opts->{fields};
+
+    my $query
+        = $type eq "module"
+        ? {
+        nested => {
+            path  => 'module',
+            query => {
+                constant_score => {
+                    filter => {
+                        bool => {
+                            must => [
+                                { term => { "module.authorized" => true } },
+                                { term => { "module.indexed"    => true } },
+                                { term => { "module.name"       => $name } },
+                            ]
+                        }
+                    }
+                }
+            }
+        }
+        }
+        : $type eq "file" ? {
+        bool => {
+            must => [
+                { term => { path         => $path } },
+                { term => { distribution => $name } },
+            ]
+        }
+        }
+
+        # XXX: to fix: no filtering on 'release' so this query
+        # will produce modules matching duplications. -- Mickey
+        : $type eq "documentation" ? {
+        bool => {
+            must => [
+                { match_phrase => { documentation => $name } },
+                { term         => { indexed       => true } },
+                { term         => { authorized    => true } },
+            ]
+        }
+        }
+        : return undef;
+
+    my $res = $self->es->search(
+        es_doc_path('file'),
+        body => {
+            query => $query,
+            size  => 500,
+            sort  => [ { date => 'desc' } ],
+            ( $source ? ( _source => $source ) : () ),
+        },
+    );
+
+    return {
+        took  => $res->{took},
+        total => hit_total($res),
+        files => [ map $_->{_source}, @{ $res->{hits}{hits} } ],
+    };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/Mirror.pm b/lib/MetaCPAN/Query/Mirror.pm
new file mode 100644
index 000000000..c16ee36e0
--- /dev/null
+++ b/lib/MetaCPAN/Query/Mirror.pm
@@ -0,0 +1,68 @@
+package MetaCPAN::Query::Mirror;
+
+use MetaCPAN::Moose;
+use MetaCPAN::Util qw( hit_total );
+
+use MetaCPAN::ESConfig qw( es_doc_path );
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub search {
+    my ( $self, $q ) = @_;
+    my $query = { match_all => {} };
+
+    if ($q) {
+        my @protocols = grep /^ (?: http | ftp | rsync ) $/x, split /\s+/, $q;
+
+        $query = {
+            bool => {
+                must => [ map +{ exists => { field => $_ } }, @protocols ]
+            },
+        };
+    }
+
+    my @sort = ( sort => [qw( continent country )] );
+
+    my $location;
+
+    if ( $q and $q =~ /loc\:([^\s]+)/ ) {
+        $location = [ split( /,/, $1 ) ];
+        if ($location) {
+            @sort = (
+                sort => {
+                    _geo_distance => {
+                        location => [ $location->[1], $location->[0] ],
+                        order    => 'asc',
+                        unit     => 'km'
+                    }
+                }
+            );
+        }
+    }
+
+    my $ret = $self->es->search(
+        es_doc_path('mirror'),
+        body => {
+            size  => 999,
+            query => $query,
+            @sort,
+        },
+    );
+
+    my $data = [
+        map +{
+            %{ $_->{_source} },
+            distance => ( $location ? $_->{sort}[0] : undef )
+        },
+        @{ $ret->{hits}{hits} }
+    ];
+
+    return {
+        mirrors => $data,
+        total   => hit_total($ret),
+        took    => $ret->{took}
+    };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/Package.pm b/lib/MetaCPAN/Query/Package.pm
new file mode 100644
index 000000000..00027f683
--- /dev/null
+++ b/lib/MetaCPAN/Query/Package.pm
@@ -0,0 +1,35 @@
+package MetaCPAN::Query::Package;
+
+use MetaCPAN::Moose;
+
+use MetaCPAN::ESConfig qw( es_doc_path );
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub get_modules {
+    my ( $self, $dist, $ver ) = @_;
+
+    my $query = +{
+        bool => {
+            must => [
+                { term => { distribution => $dist } },
+                { term => { dist_version => $ver } },
+            ],
+        }
+    };
+
+    my $res = $self->es->search(
+        es_doc_path('package'),
+        body => {
+            query   => $query,
+            size    => 999,
+            _source => [qw< module_name >],
+        }
+    );
+
+    return +{ modules =>
+            [ map { $_->{_source}{module_name} } @{ $res->{hits}{hits} } ] };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/Permission.pm b/lib/MetaCPAN/Query/Permission.pm
new file mode 100644
index 000000000..311a68aab
--- /dev/null
+++ b/lib/MetaCPAN/Query/Permission.pm
@@ -0,0 +1,62 @@
+package MetaCPAN::Query::Permission;
+
+use MetaCPAN::Moose;
+
+use MetaCPAN::ESConfig qw( es_doc_path );
+use Ref::Util          qw( is_arrayref );
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub by_author {
+    my ( $self, $pauseid ) = @_;
+
+    my $body = {
+        query => {
+            bool => {
+                should => [
+                    { term => { owner          => $pauseid } },
+                    { term => { co_maintainers => $pauseid } },
+                ],
+            },
+        },
+        size => 5_000,
+    };
+
+    my $ret = $self->es->search( es_doc_path('permission'), body => $body, );
+
+    my $data = [
+        sort { $a->{module_name} cmp $b->{module_name} }
+        map  { $_->{_source} } @{ $ret->{hits}{hits} }
+    ];
+
+    return { permissions => $data };
+}
+
+sub by_modules {
+    my ( $self, $modules ) = @_;
+    $modules = [$modules] unless is_arrayref($modules);
+
+    my @modules = map +{ term => { module_name => $_ } },
+        grep defined, @{$modules};
+    return { permissions => [] }
+        unless @modules;
+
+    my $body = {
+        query => {
+            bool => { should => \@modules }
+        },
+        size => 1_000,
+    };
+
+    my $ret = $self->es->search( es_doc_path('permission'), body => $body, );
+
+    my $data = [
+        sort { $a->{module_name} cmp $b->{module_name} }
+        map  { $_->{_source} } @{ $ret->{hits}{hits} }
+    ];
+
+    return { permissions => $data };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/Release.pm b/lib/MetaCPAN/Query/Release.pm
new file mode 100644
index 000000000..b45011e25
--- /dev/null
+++ b/lib/MetaCPAN/Query/Release.pm
@@ -0,0 +1,1142 @@
+package MetaCPAN::Query::Release;
+use v5.20;
+
+use MetaCPAN::Moose;
+
+use MetaCPAN::ESConfig qw( es_doc_path );
+use MetaCPAN::Util
+    qw( MAX_RESULT_WINDOW hit_total single_valued_arrayref_to_scalar true false );
+
+with 'MetaCPAN::Query::Role::Common';
+
+sub author_status {
+    my ( $self, $id, $file ) = @_;
+    return unless $id and $file;
+
+    my $status = $file->{_source}
+        || single_valued_arrayref_to_scalar( $file->{fields} );
+
+    if ( $status and $status->{pauseid} ) {
+        $status->{release_count}
+            = $self->aggregate_status_by_author( $status->{pauseid} );
+
+        my ( $id_2, $id_1 ) = $id =~ /^((\w)\w)/;
+        $status->{links} = {
+            cpan_directory =>
+                "https://www.cpan.org/authors/id/$id_1/$id_2/$id",
+            backpan_directory =>
+                "https://cpan.metacpan.org/authors/id/$id_1/$id_2/$id",
+            cpants => "https://cpants.cpanauthors.org/author/$id",
+            cpantesters_reports =>
+                "https://www.cpantesters.org/author/$id_1/$id.html",
+            cpantesters_matrix =>
+                "https://matrix.cpantesters.org/?author=$id",
+            metacpan_explorer =>
+                "https://explorer.metacpan.org/?url=/author/$id",
+            repology => "https://repology.org/maintainer/$id%40cpan",
+        };
+    }
+
+    return $status;
+}
+
+sub aggregate_status_by_author {
+    my ( $self, $pauseid ) = @_;
+    my $agg = $self->es->search( {
+        es_doc_path('release'),
+        body => {
+            query => {
+                term => { author => $pauseid }
+            },
+            aggregations => {
+                count => { terms => { field => 'status' } }
+            },
+            size => 0,
+        }
+    } );
+    my %ret = ( cpan => 0, latest => 0, backpan => 0 );
+    if ($agg) {
+        $ret{ $_->{'key'} } = $_->{'doc_count'}
+            for @{ $agg->{'aggregations'}{'count'}{'buckets'} };
+    }
+    $ret{'backpan-only'} = delete $ret{'backpan'};
+    return \%ret;
+}
+
+sub get_contributors {
+    my ( $self, $author_name, $release_name ) = @_;
+
+    my $res = $self->es->search(
+        es_doc_path('contributor'),
+        body => {
+            query => {
+                bool => {
+                    must => [
+                        { term => { release_name   => $release_name } },
+                        { term => { release_author => $author_name } },
+                    ],
+                },
+            },
+            size    => 999,
+            _source => [qw< email name pauseid >],
+        }
+    );
+
+    my @contribs = map $_->{_source}, @{ $res->{hits}{hits} };
+
+    @contribs = sort { fc $a->{name} cmp fc $b->{name} } @contribs;
+
+    return { contributors => \@contribs };
+}
+
+sub get_files {
+    my ( $self, $release, $files ) = @_;
+
+    my $query = +{
+        query => {
+            bool => {
+                must => [
+                    { term  => { release => $release } },
+                    { terms => { name    => $files } }
+                ],
+            }
+        }
+    };
+
+    my $ret = $self->es->search(
+        es_doc_path('file'),
+        body => {
+            query   => $query,
+            size    => 999,
+            _source => [qw< name path >],
+        }
+    );
+
+    return {} unless @{ $ret->{hits}{hits} };
+
+    return { files => [ map { $_->{_source} } @{ $ret->{hits}{hits} } ] };
+}
+
+sub get_checksums {
+    my ( $self, $release ) = @_;
+
+    my $query = { term => { name => $release } };
+
+    my $ret = $self->es->search(
+        es_doc_path('release'),
+        body => {
+            query   => $query,
+            size    => 1,
+            _source => [qw< checksum_md5 checksum_sha256 >],
+        }
+    );
+
+    return {} unless @{ $ret->{hits}{hits} };
+    return $ret->{hits}{hits}[0]{_source};
+}
+
+sub _activity_filters {
+    my ( $self, $params, $start ) = @_;
+    my ( $author, $distribution, $module, $new_dists )
+        = @{$params}{qw( author distribution module new_dists )};
+
+    my @filters
+        = ( { range => { date => { from => $start->epoch . '000' } } } );
+
+    push @filters, +{ term => { author => uc($author) } }
+        if $author;
+
+    push @filters, +{ term => { distribution => $distribution } }
+        if $distribution;
+
+    push @filters, +{ term => { 'dependency.module' => $module } }
+        if $module;
+
+    if ( $new_dists and $new_dists eq 'n' ) {
+        push @filters,
+            (
+            +{ term  => { first  => true } },
+            +{ terms => { status => [qw( cpan latest )] } },
+            );
+    }
+
+    return +{ bool => { must => \@filters } };
+}
+
+sub activity {
+    my ( $self, $params ) = @_;
+    my $res = $params->{res} // '1w';
+
+    my $start
+        = DateTime->now->truncate( to => 'month' )->subtract( months => 23 );
+
+    my $filters = $self->_activity_filters( $params, $start );
+
+    my $body = {
+        query        => { match_all => {} },
+        aggregations => {
+            histo => {
+                filter       => $filters,
+                aggregations => {
+                    entries => {
+                        date_histogram =>
+                            { field => 'date', interval => $res },
+                    }
+                }
+            }
+        },
+        size => 0,
+    };
+
+    my $ret = $self->es->search( es_doc_path('release'), body => $body, );
+
+    my $data = { map { $_->{key} => $_->{doc_count} }
+            @{ $ret->{aggregations}{histo}{entries}{buckets} } };
+
+    my $line = [
+        map {
+            $data->{ $start->clone->add( months => $_ )->epoch . '000' }
+                || 0
+        } ( 0 .. 23 )
+    ];
+
+    return { activity => $line };
+}
+
+sub by_author_and_name {
+    my ( $self, $author, $release ) = @_;
+
+    my $body = {
+        query => {
+            bool => {
+                must => [
+                    { term => { 'name' => $release } },
+                    { term => { author => uc($author) } }
+                ]
+            }
+        }
+    };
+
+    my $ret = $self->es->search( es_doc_path('release'), body => $body, );
+
+    my $data = $ret->{hits}{hits}[0]{_source};
+
+    return {
+        took    => $ret->{took},
+        release => $data,
+        total   => hit_total($ret),
+    };
+}
+
+sub by_author_and_names {
+    my ( $self, $releases ) = @_;
+
+    # $releases: ArrayRef[ Dict[ author => Str, name => Str ] ]
+
+    my $body = {
+        size  => ( 0 + @$releases ),
+        query => {
+            bool => {
+                should => [
+                    map {
+                        +{
+                            bool => {
+                                must => [
+                                    {
+                                        term => {
+                                            author => uc( $_->{author} )
+                                        }
+                                    },
+                                    { term => { 'name' => $_->{name} } },
+                                ]
+                            }
+                        }
+                    } @$releases
+                ]
+            }
+        }
+    };
+
+    my $ret = $self->es->search( es_doc_path('release'), body => $body, );
+
+    my @releases;
+    for my $hit ( @{ $ret->{hits}{hits} } ) {
+        my $src = $hit->{_source};
+        push @releases, $src;
+    }
+
+    return {
+        took     => $ret->{took},
+        total    => hit_total($ret),
+        releases => \@releases,
+    };
+}
+
+sub by_author {
+    my ( $self, $pauseid, $page, $size ) = @_;
+    $size //= 1000;
+    $page //= 1;
+
+    if ( $page * $size >= MAX_RESULT_WINDOW ) {
+        return {
+            releases => [],
+            took     => 0,
+            total    => 0,
+        };
+    }
+
+    my $body = {
+        query => {
+            bool => {
+                must => [
+                    { terms => { status => [qw< cpan latest >] } },
+                    ( $pauseid ? { term => { author => $pauseid } } : () ),
+                ],
+            }
+        },
+        sort =>
+            [ 'distribution', { 'version_numified' => { reverse => 1 } } ],
+        _source => [
+            qw( abstract author authorized date distribution license metadata.version resources.repository status tests )
+        ],
+        size => $size,
+        from => ( $page - 1 ) * $size,
+    };
+
+    my $ret = $self->es->search( es_doc_path('release'), body => $body, );
+
+    my $data = [ map { $_->{_source} } @{ $ret->{hits}{hits} } ];
+
+    return {
+        releases => $data,
+        total    => hit_total($ret),
+        took     => $ret->{took}
+    };
+}
+
+sub latest_by_distribution {
+    my ( $self, $distribution ) = @_;
+
+    my $body = {
+        query => {
+            bool => {
+                must => [
+                    {
+                        term => {
+                            'distribution' => $distribution
+                        }
+                    },
+                    { term => { status => 'latest' } }
+                ]
+            }
+        },
+        sort => [ { date => 'desc' } ],
+        size => 1
+    };
+
+    my $ret = $self->es->search( es_doc_path('release'), body => $body, );
+
+    my $data = $ret->{hits}{hits}[0]{_source};
+
+    return {
+        release => $data,
+        took    => $ret->{took},
+        total   => hit_total($ret),
+    };
+}
+
+sub latest_by_author {
+    my ( $self, $pauseid ) = @_;
+
+    my $body = {
+        query => {
+            bool => {
+                must => [
+                    { term => { author => uc($pauseid) } },
+                    { term => { status => 'latest' } }
+                ]
+            }
+        },
+        sort =>
+            [ 'distribution', { 'version_numified' => { reverse => 1 } } ],
+        _source => [
+            qw(author distribution name status abstract date download_url version authorized maturity)
+        ],
+        size => 1000,
+    };
+
+    my $ret = $self->es->search( es_doc_path('release'), body => $body, );
+
+    my $data = [ map { $_->{_source} } @{ $ret->{hits}{hits} } ];
+
+    return { took => $ret->{took}, releases => $data };
+}
+
+sub all_by_author {
+    my ( $self, $author, $page, $size ) = @_;
+    $size //= 100;
+    $page //= 1;
+
+    if ( $page * $size >= MAX_RESULT_WINDOW ) {
+        return {
+            releases => [],
+            took     => 0,
+            total    => 0,
+        };
+    }
+
+    my $body = {
+        query   => { term => { author => uc($author) } },
+        sort    => [ { date => 'desc' } ],
+        _source => [
+            qw(author distribution name status abstract date download_url version authorized maturity)
+        ],
+        size => $size,
+        from => ( $page - 1 ) * $size,
+    };
+    my $ret = $self->es->search( es_doc_path('release'), body => $body, );
+
+    my $data = [ map { $_->{_source} } @{ $ret->{hits}{hits} } ];
+
+    return {
+        took     => $ret->{took},
+        releases => $data,
+        total    => hit_total($ret),
+    };
+}
+
+sub versions {
+    my ( $self, $dist, $versions ) = @_;
+
+    my $size = 1000;
+
+    my $query;
+
+    # 'versions' param was sent
+    if ( @{$versions} ) {
+        my $filter_versions;
+
+        # we only want 'latest' version
+        if ( @{$versions} == 1 and $versions->[0] eq 'latest' ) {
+            $filter_versions = { term => { status => 'latest' } };
+        }
+        else {
+            if ( grep $_ eq 'latest', @{$versions} ) {
+
+                # we want a combination of 'latest' and specific versions
+                @{$versions} = grep $_ ne 'latest', @{$versions};
+                $filter_versions = {
+                    bool => {
+                        should => [
+                            { terms => { version => $versions } },
+                            { term  => { status  => 'latest' } },
+                        ],
+                    }
+                };
+            }
+            else {
+                # we only want specific versions
+                $filter_versions = { terms => { version => $versions } };
+            }
+        }
+
+        $query = {
+            bool => {
+                must => [
+                    { term => { distribution => $dist } },
+                    $filter_versions
+                ]
+            }
+        };
+    }
+    else {
+        $query = { term => { distribution => $dist } };
+    }
+
+    my $body = {
+        query   => $query,
+        size    => $size,
+        sort    => [ { date => 'desc' } ],
+        _source => [ qw(
+            name
+            date
+            author
+            version
+            status
+            maturity
+            authorized
+            download_url
+            main_module
+        ) ],
+    };
+
+    my $ret = $self->es->search( es_doc_path('release'), body => $body, );
+
+    my $data = [ map { $_->{_source} } @{ $ret->{hits}{hits} } ];
+
+    return {
+        releases => $data,
+        total    => hit_total($ret),
+        took     => $ret->{took}
+    };
+}
+
+sub top_uploaders {
+    my ( $self, $range ) = @_;
+    my $range_filter = {
+        range => {
+            date => {
+                from => $range eq 'all' ? 0 : DateTime->now->subtract(
+                      $range eq 'weekly'  ? 'weeks'
+                    : $range eq 'monthly' ? 'months'
+                    : $range eq 'yearly'  ? 'years'
+                    :                       'weeks' => 1
+                )->truncate( to => 'day' )->iso8601
+            },
+        }
+    };
+
+    my $body = {
+        query        => { match_all => {} },
+        aggregations => {
+            author => {
+                aggregations => {
+                    entries => {
+                        terms => { field => 'author', size => 50 }
+                    }
+                },
+                filter => $range_filter,
+            },
+        },
+        size => 0,
+    };
+
+    my $ret = $self->es->search( es_doc_path('release'), body => $body, );
+
+    my $counts = { map { $_->{key} => $_->{doc_count} }
+            @{ $ret->{aggregations}{author}{entries}{buckets} } };
+
+    return {
+        counts => $counts,
+        took   => $ret->{took}
+    };
+}
+
+sub requires {
+    my ( $self, $module, $page, $page_size, $sort ) = @_;
+    return $self->_get_depended_releases( [$module], $page, $page_size,
+        $sort );
+}
+
+sub reverse_dependencies {
+    my ( $self, $distribution, $page, $page_size, $sort ) = @_;
+
+    # get the latest release of given distribution
+    my $release = $self->_get_latest_release($distribution) || return;
+
+    # get (authorized/indexed) modules provided by the release
+    my $modules = $self->_get_provided_modules($release) || return;
+
+    # return releases depended on those modules
+    return $self->_get_depended_releases( $modules, $page, $page_size,
+        $sort );
+}
+
+sub _get_latest_release {
+    my ( $self, $distribution ) = @_;
+
+    my $release = $self->es->search(
+        es_doc_path('release'),
+        body => {
+            query => {
+                bool => {
+                    must => [
+                        { term => { distribution => $distribution } },
+                        { term => { status       => 'latest' } },
+                        { term => { authorized   => true } },
+                    ]
+                },
+            },
+            _source => [qw< name author >],
+        },
+    );
+
+    my ($release_info) = map { $_->{_source} } @{ $release->{hits}{hits} };
+
+    return $release_info->{name} && $release_info->{author}
+        ? +{
+        name   => $release_info->{name},
+        author => $release_info->{author},
+        }
+        : undef;
+}
+
+sub _get_provided_modules {
+    my ( $self, $release ) = @_;
+
+    my $provided_modules = $self->es->search(
+        es_doc_path('file'),
+        body => {
+            query => {
+                bool => {
+                    must => [
+                        { term => { 'release' => $release->{name} } },
+                        { term => { 'author'  => $release->{author} } },
+                        { term => { 'module.authorized' => true } },
+                        { term => { 'module.indexed'    => true } },
+                    ]
+                }
+            },
+            size => 999,
+        }
+    );
+
+    my @modules = map { $_->{name} }
+        grep { $_->{indexed} && $_->{authorized} }
+        map  { @{ $_->{_source}{module} } }
+        @{ $provided_modules->{hits}{hits} };
+
+    return @modules ? \@modules : undef;
+}
+
+sub _fix_sort_value {
+    my $sort = shift;
+
+    if ( $sort && $sort =~ /^(\w+):(asc|desc)$/ ) {
+        return { $1 => $2 };
+    }
+    else {
+        return { date => 'desc' };
+    }
+}
+
+sub _get_depended_releases {
+    my ( $self, $modules, $page, $page_size, $sort ) = @_;
+    $page      //= 1;
+    $page_size //= 50;
+
+    if ( $page * $page_size >= MAX_RESULT_WINDOW ) {
+        return +{
+            data  => [],
+            took  => 0,
+            total => 0,
+        };
+    }
+
+    $sort = _fix_sort_value($sort);
+
+    my $dependency_filter = {
+        nested => {
+            path  => 'dependency',
+            query => {
+                bool => {
+                    must => [
+                        {
+                            term =>
+                                { 'dependency.relationship' => 'requires' }
+                        },
+                        {
+                            terms => {
+                                'dependency.phase' => [ qw(
+                                    configure
+                                    build
+                                    runtime
+                                    test
+                                ) ]
+                            }
+                        },
+                        { terms => { 'dependency.module' => $modules } },
+                    ],
+                },
+            },
+        },
+    };
+
+    my $depended = $self->es->search(
+        es_doc_path('release'),
+        body => {
+            query => {
+                bool => {
+                    must => [
+                        $dependency_filter,
+                        { term => { status     => 'latest' } },
+                        { term => { authorized => true } },
+                    ],
+                },
+            },
+            size => $page_size,
+            from => ( $page - 1 ) * $page_size,
+            sort => $sort,
+        }
+    );
+
+    return +{
+        data  => [ map { $_->{_source} } @{ $depended->{hits}{hits} } ],
+        total => hit_total($depended),
+        took  => $depended->{took},
+    };
+}
+
+sub recent {
+    my ( $self, $type, $page, $page_size ) = @_;
+    $page      //= 1;
+    $page_size //= 10000;
+    $type      //= '';
+
+    if ( $page * $page_size >= MAX_RESULT_WINDOW ) {
+        return +{
+            releases => [],
+            took     => 0,
+            total    => 0,
+        };
+    }
+
+    my $query;
+    if ( $type eq 'n' ) {
+        $query = {
+            bool => {
+                must => [
+                    { term  => { first  => true } },
+                    { terms => { status => [qw< cpan latest >] } },
+                ]
+            }
+        };
+    }
+    elsif ( $type eq 'a' ) {
+        $query = { match_all => {} };
+    }
+    else {
+        $query = { terms => { status => [qw< cpan latest >] } };
+    }
+
+    my $body = {
+        size    => $page_size,
+        from    => ( $page - 1 ) * $page_size,
+        query   => $query,
+        _source =>
+            [qw(name author status abstract date distribution maturity)],
+        sort => [ { 'date' => { order => 'desc' } } ]
+    };
+
+    my $ret = $self->es->search( es_doc_path('release'), body => $body, );
+
+    my $data = [ map { $_->{_source} } @{ $ret->{hits}{hits} } ];
+
+    return {
+        releases => $data,
+        total    => hit_total($ret),
+        took     => $ret->{took}
+    };
+}
+
+sub modules {
+    my ( $self, $author, $release ) = @_;
+
+    my $body = {
+        query => {
+            bool => {
+                must => [
+                    { term => { release   => $release } },
+                    { term => { author    => $author } },
+                    { term => { directory => false } },
+                    {
+                        bool => {
+                            should => [
+                                {
+                                    bool => {
+                                        must => [
+                                            {
+                                                exists => {
+                                                    field => 'module.name'
+                                                }
+                                            },
+                                            {
+                                                term => {
+                                                    'module.indexed' => true
+                                                }
+                                            }
+                                        ]
+                                    }
+                                },
+                                {
+                                    bool => {
+                                        must => [
+                                            {
+                                                range => {
+                                                    slop => { gt => 0 }
+                                                }
+                                            },
+                                            {
+                                                exists => {
+                                                    field => 'pod.analyzed'
+                                                }
+                                            },
+                                            {
+                                                term => { 'indexed' => true }
+                                            },
+                                        ]
+                                    }
+                                }
+                            ]
+                        }
+                    }
+                ]
+            }
+        },
+        size => 999,
+
+        # Sort by documentation name; if there isn't one, sort by path.
+        sort => [ 'documentation', 'path' ],
+
+        _source => [ qw(
+            module
+            abstract
+            author
+            authorized
+            distribution
+            documentation
+            indexed
+            path
+            pod_lines
+            release
+            status
+        ) ],
+    };
+
+    my $ret = $self->es->search( es_doc_path('file'), body => $body, );
+
+    my @files = map $_->{_source}, @{ $ret->{hits}{hits} };
+
+    return {
+        files => \@files,
+        total => hit_total($ret),
+        took  => $ret->{took}
+    };
+}
+
+=head2 find_download_url
+
+cpanm Foo
+=> status: latest, maturity: released
+
+cpanm --dev Foo
+=> status: -backpan, sort_by: version_numified,date
+
+cpanm Foo~1.0
+=> status: latest, maturity: released, module.version_numified: gte: 1.0
+
+cpanm --dev Foo~1.0
+-> status: -backpan, module.version_numified: gte: 1.0, sort_by: version_numified,date
+
+cpanm Foo~<2
+=> maturity: released, module.version_numified: lt: 2, sort_by: status,version_numified,date
+
+cpanm --dev Foo~<2
+=> status: -backpan, module.version_numified: lt: 2, sort_by: status,version_numified,date
+
+    $release->find_download_url( 'module', 'Foo', { version => $version, dev => 0|1 });
+
+Sorting:
+
+    if it's stable:
+      prefer latest > cpan > backpan
+      then sort by version desc
+      then sort by date descending (rev chron)
+
+    if it's dev:
+      sort by version desc
+      sort by date descending (reverse chronologically)
+
+
+=cut
+
+sub find_download_url {
+    my ( $self, $type, $name, $args ) = @_;
+    $args ||= {};
+
+    my $dev              = $args->{dev};
+    my $version          = $args->{version};
+    my $explicit_version = $version && $version =~ /==/;
+
+    my @filters;
+
+    die 'type must be module or dist'
+        unless $type eq 'module' || $type eq 'dist';
+    my $module_filter = $type eq 'module';
+
+    if ( !$explicit_version ) {
+        push @filters,
+            { bool => { must_not => [ { term => { status => 'backpan' } } ] }
+            };
+        if ( !$dev ) {
+            push @filters, { term => { maturity => 'released' } };
+        }
+    }
+
+    my $prefix = $module_filter ? 'module.' : '';
+
+    my $version_filters
+        = $self->_version_filters( $version, $prefix . 'version_numified' );
+
+    my $entity_filter = {
+        bool => {
+            must => [
+                { term => { $prefix . 'authorized' => true } },
+                (
+                    $module_filter
+                    ? (
+                        { term => { $prefix . 'indexed' => true } },
+                        { term => { $prefix . 'name'    => $name } }
+                        )
+                    : { term => { 'distribution' => $name } },
+                ),
+                (
+                    exists $version_filters->{must}
+                    ? @{ $version_filters->{must} }
+                    : ()
+                )
+            ],
+            (
+                exists $version_filters->{must_not}
+                ? ( must_not => [ @{ $version_filters->{must_not} } ] )
+                : ()
+            )
+        }
+    };
+
+    # filters to be applied to the nested modules
+    if ($module_filter) {
+        push @filters,
+            {
+            nested => {
+                path  => 'module',
+                query => $entity_filter,
+            }
+            };
+    }
+    else {
+        push @filters, $entity_filter;
+    }
+
+    my $filter
+        = @filters
+        ? { bool => { must => \@filters } }
+        : $filters[0];
+
+    my $version_sort
+        = $module_filter
+        ? {
+        'module.version_numified' => {
+            mode  => 'max',
+            order => 'desc',
+            (
+                $self->es->api_version ge '6_0'
+                ? (
+                    nested => {
+                        path   => 'module',
+                        filter => $entity_filter,
+                    },
+                    )
+                : (
+                    nested_path   => 'module',
+                    nested_filter => $entity_filter,
+                )
+            ),
+        }
+        }
+        : { version_numified => { order => 'desc' } };
+
+    # sort by score, then version desc, then date desc
+    my @sort = ( '_score', $version_sort, { date => { order => 'desc' } } );
+
+    my $query;
+
+    if ($dev) {
+        $query = $filter;
+    }
+    else {
+        # if not dev, then prefer latest > cpan > backpan
+        $query = {
+            function_score => {
+                query      => $filter,
+                score_mode => 'first',
+                boost_mode => 'replace',
+                functions  => [
+                    {
+                        filter => { term => { status => 'latest' } },
+                        weight => 3
+                    },
+                    {
+                        filter => { term => { status => 'cpan' } },
+                        weight => 2
+                    },
+                    { filter => { match_all => {} }, weight => 1 },
+                ]
+            }
+        };
+    }
+
+    my $body = {
+        query   => $query,
+        size    => 1,
+        sort    => \@sort,
+        _source => [ qw(
+            checksum_md5
+            checksum_sha256
+            date
+            distribution
+            download_url
+            release
+            status
+            version
+            name
+        ) ],
+    };
+
+    my $res = $self->es->search(
+        es_doc_path( $module_filter ? 'file' : 'release' ),
+        body        => $body,
+        search_type => 'dfs_query_then_fetch',
+    );
+
+    return unless hit_total($res);
+
+    my @checksums;
+
+    my $hit     = $res->{hits}{hits}[0];
+    my $source  = $hit->{_source};
+    my $release = $source->{release};
+
+    if ($release) {
+        my $checksums = $self->get_checksums($release);
+        @checksums = (
+            (
+                $checksums->{checksum_md5}
+                ? ( checksum_md5 => $checksums->{checksum_md5} )
+                : ()
+            ),
+            (
+                $checksums->{checksum_sha256}
+                ? ( checksum_sha256 => $checksums->{checksum_sha256} )
+                : ()
+            ),
+        );
+    }
+
+    my $source_name = delete $source->{name};
+    if ( !$module_filter ) {
+        $source->{release} = $source_name;
+    }
+
+    my $module
+        = $hit->{inner_hits}{module}
+        ? $hit->{inner_hits}{module}{hits}{hits}[0]{_source}
+        : {};
+
+    return +{ %$source, %$module, @checksums, };
+}
+
+sub _version_filters {
+    my ( $self, $version, $field ) = @_;
+
+    return () unless $version;
+
+    if ( $version =~ s/^==\s*// ) {
+        return +{
+            must => [ {
+                term => {
+                    $field => $self->_numify($version)
+                }
+            } ]
+        };
+    }
+    elsif ( $version =~ /^[<>!]=?\s*/ ) {
+        my %ops = qw(< lt <= lte > gt >= gte);
+        my ( %filters, %range, @exclusion );
+        my @requirements = split /,\s*/, $version;
+        for my $r (@requirements) {
+            if ( $r =~ s/^([<>]=?)\s*// ) {
+                $range{ $ops{$1} } = $self->_numify($r);
+            }
+            elsif ( $r =~ s/\!=\s*// ) {
+                push @exclusion, $self->_numify($r);
+            }
+        }
+
+        if ( keys %range ) {
+            $filters{must}
+                = [ { range => { $field => \%range } } ];
+        }
+
+        if (@exclusion) {
+            $filters{must_not} = [];
+            push @{ $filters{must_not} },
+                map { +{ term => { $field => $self->_numify($_) } } }
+                @exclusion;
+        }
+
+        return \%filters;
+    }
+    elsif ( $version !~ /\s/ ) {
+        return +{
+            must => [ {
+                range => {
+                    $field => { 'gte' => $self->_numify($version) }
+                },
+            } ]
+        };
+    }
+}
+
+sub _numify {
+    my ( $self, $ver ) = @_;
+    $ver =~ s/_//g;
+    version->new($ver)->numify;
+}
+
+sub predecessor {
+    my ( $self, $name ) = @_;
+
+    my $res = $self->es->search(
+        es_doc_path('release'),
+        body => {
+            query => {
+                bool => {
+                    must     => [ { term => { distribution => $name } }, ],
+                    must_not => [ { term => { status       => 'latest' } }, ],
+                },
+            },
+            sort => [ { date => 'desc' } ],
+            size => 1,
+        },
+    );
+    my ($release) = $res->{hits}{hits}[0];
+    return unless $release;
+    return $release->{_source};
+}
+
+sub find {
+    my ( $self, $name ) = @_;
+
+    my $res = $self->es->search(
+        es_doc_path('release'),
+        body => {
+            query => {
+                bool => {
+                    must => [
+                        { term => { distribution => $name } },
+                        { term => { status       => 'latest' } },
+                    ],
+                },
+            },
+            sort => [ { date => 'desc' } ],
+            size => 1,
+        },
+    );
+    my ($file) = $res->{hits}{hits}[0];
+    return undef unless $file;
+    return $file->{_source};
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Query/Role/Common.pm b/lib/MetaCPAN/Query/Role/Common.pm
new file mode 100644
index 000000000..bc86b311b
--- /dev/null
+++ b/lib/MetaCPAN/Query/Role/Common.pm
@@ -0,0 +1,46 @@
+package MetaCPAN::Query::Role::Common;
+use Moose::Role;
+
+use MetaCPAN::Types::TypeTiny qw( ES );
+
+has es => (
+    is       => 'ro',
+    required => 1,
+    isa      => ES,
+    coerce   => 1,
+);
+
+sub name {
+    my $self  = shift;
+    my $class = ref $self || $self;
+
+    $class =~ /^MetaCPAN::Query::([^:]+)$/
+        or return undef;
+    return lc $1;
+}
+
+has _in_query => (
+    is       => 'ro',
+    init_arg => 'query',
+    weak_ref => 1,
+);
+
+has _gen_query => (
+    is       => 'ro',
+    lazy     => 1,
+    init_arg => undef,
+    default  => sub {
+        my $self = shift;
+        my $name = $self->name;
+
+        require MetaCPAN::Query;
+        MetaCPAN::Query->new(
+            es => $self->es,
+            ( $name ? ( $name => $self ) : () ),
+        );
+    },
+);
+
+sub query { $_[0]->_in_query // $_[0]->_gen_query }
+
+1;
diff --git a/lib/MetaCPAN/Query/Search.pm b/lib/MetaCPAN/Query/Search.pm
new file mode 100644
index 000000000..d5cf21897
--- /dev/null
+++ b/lib/MetaCPAN/Query/Search.pm
@@ -0,0 +1,397 @@
+package MetaCPAN::Query::Search;
+
+use MetaCPAN::Moose;
+
+use Const::Fast        qw( const );
+use Hash::Merge        qw( merge );
+use List::Util         qw( min uniq );
+use Log::Contextual    qw( :log :dlog );
+use MetaCPAN::ESConfig qw( es_doc_path );
+use MetaCPAN::Util     qw( MAX_RESULT_WINDOW hit_total true false );
+use MooseX::StrictConstructor;
+
+with 'MetaCPAN::Query::Role::Common';
+
+const my $RESULTS_PER_RUN => 200;
+const my @ROGUE_DISTRIBUTIONS => qw(
+    Acme-DependOnEverything
+    Bundle-Everything
+    kurila
+    perl-5.005_02+apache1.3.3+modperl
+    perlbench
+    perl_debug
+    perl_mlb
+    pod2texi
+    spodcxx
+);
+
+sub search_for_first_result {
+    my ( $self, $search_term ) = @_;
+    my $es_query   = $self->build_query($search_term);
+    my $es_results = $self->run_query( file => $es_query );
+
+    my $data = $es_results->{hits}{hits}[0];
+    return $data->{_source};
+}
+
+=head2 search_web
+
+  search_web( $search_term, $from, $page_size, $collapsed );
+
+- search_term:
+   - can be unqualified string e.g. 'paging'
+   - can be author e.g: 'author:LLAP'
+   - can be module e.g.: 'module:Data::Pageset'
+   - can be distribution e.g.: 'dist:Data-Pageset'
+
+- from: where in result set to start, int
+
+- page_size: number of results per page, int
+
+- collapsed: whether to merge results by dist or not
+
+=cut
+
+sub search_web {
+    my ( $self, $search_term, $page, $page_size, $collapsed,
+        $max_collapsed_hits )
+        = @_;
+    $page_size //= 20;
+    $page      //= 1;
+
+    if ( $page * $page_size >= MAX_RESULT_WINDOW ) {
+        return {
+            results  => [],
+            total    => 0,
+            tool     => 0,
+            colapsed => $collapsed ? true : false,
+        };
+    }
+
+    $search_term =~ s{([+=>_search_collapsed( $search_term, $page, $page_size,
+        $max_collapsed_hits )
+        : $self->_search_expanded( $search_term, $page, $page_size );
+
+    return $results;
+}
+
+sub _search_expanded {
+    my ( $self, $search_term, $page, $page_size ) = @_;
+
+    # Used for distribution and module searches, the limit is included in
+    # the query and ES does the right thing (because we are not collapsing
+    # results by distribution).
+    my $es_query = $self->build_query(
+        $search_term,
+        {
+            size => $page_size,
+            from => ( $page - 1 ) * $page_size,
+        }
+    );
+
+    my $es_results = $self->run_query( file => $es_query );
+
+    # Extract results from es
+    my $results = $self->_extract_results($es_results);
+
+    $results = [
+        map { {
+            hits         => [$_],
+            distribution => $_->{distribution},
+            total        => 1,
+        } } @$results
+    ];
+
+    my $return = {
+        results   => $results,
+        total     => hit_total($es_results),
+        took      => $es_results->{took},
+        collapsed => false,
+    };
+    return $return;
+}
+
+sub _search_collapsed {
+    my ( $self, $search_term, $page, $page_size, $max_collapsed_hits ) = @_;
+
+    $max_collapsed_hits ||= 5;
+
+    my $from       = ( $page - 1 ) * $page_size;
+    my $total_size = $page * $page_size;
+
+    my $es_query_opts = {
+        size    => 0,
+        _source => [ qw(
+        ) ],
+    };
+
+    my $es_query = $self->build_query( $search_term, $es_query_opts );
+    my $source   = delete $es_query->{_source};
+
+    my $script_key = $self->es->api_version ge '5_0' ? 'source' : 'inline';
+
+    $es_query->{aggregations} = {
+        by_dist => {
+            terms => {
+                size  => $total_size,
+                field => 'distribution',
+                order => {
+                    max_score => 'desc',
+                },
+            },
+            aggregations => {
+                top_files => {
+                    top_hits => {
+                        _source => $source,
+                        size    => $max_collapsed_hits,
+                    },
+                },
+                max_score => {
+                    max => {
+                        script => {
+                            lang        => "expression",
+                            $script_key => "_score",
+                        },
+                    },
+                },
+            },
+        },
+        total_dists => {
+            cardinality => {
+                field => 'distribution',
+            },
+        },
+    };
+
+    my $es_results = $self->run_query( file => $es_query );
+
+    my $output = {
+        results   => [],
+        total     => $es_results->{aggregations}{total_dists}{value},
+        took      => $es_results->{took},
+        collapsed => true,
+    };
+
+    my $last = min( $total_size - 1,
+        $#{ $es_results->{aggregations}{by_dist}{buckets} } );
+    my @dists = @{ $es_results->{aggregations}{by_dist}{buckets} }
+        [ $from .. $last ];
+
+    @{ $output->{results} } = map {
+        +{
+            hits         => $self->_extract_results( $_->{top_files} ),
+            distribution => $_->{key},
+            total        => $_->{doc_count},
+        };
+    } @dists;
+
+    return $output;
+}
+
+sub build_query {
+    my ( $self, $search_term, $params ) = @_;
+    $params //= {};
+    ( my $clean = $search_term ) =~ s/::/ /g;
+
+    my $query = {
+        bool => {
+            filter => [
+                { term => { status     => 'latest' } },
+                { term => { authorized => true } },
+                { term => { indexed    => true } },
+                {
+                    bool => {
+                        should => [
+                            {
+                                bool => {
+                                    must => [
+                                        {
+                                            exists =>
+                                                { field => 'module.name' }
+                                        },
+                                        {
+                                            term =>
+                                                { 'module.indexed' => true }
+                                        }
+                                    ],
+                                }
+                            },
+                            { exists => { field => 'documentation' } },
+                        ],
+                    }
+                },
+            ],
+            must_not => [ {
+                terms => {
+                    distribution => [ $self->query->distribution->rogue_list ]
+                }
+            } ],
+            must => [
+                {
+                    bool => {
+                        should => [
+
+                            # exact matches result in a huge boost
+                            {
+                                term => {
+                                    'documentation' => {
+                                        value => $search_term,
+                                        boost => 20,
+                                    }
+                                }
+                            },
+                            {
+                                term => {
+                                    'module.name' => {
+                                        value => $search_term,
+                                        boost => 20,
+                                    }
+                                }
+                            },
+
+            # take the maximum score from the module name and the abstract/pod
+                            {
+                                dis_max => {
+                                    queries => [
+                                        {
+                                            query_string => {
+                                                fields => [
+                                                    qw(documentation.analyzed^2 module.name.analyzed^2 distribution.analyzed),
+                                                    qw(documentation.camelcase module.name.camelcase distribution.camelcase)
+                                                ],
+                                                query            => $clean,
+                                                boost            => 3,
+                                                default_operator => 'AND',
+                                                allow_leading_wildcard =>
+                                                    false,
+
+                                            }
+                                        },
+                                        {
+                                            query_string => {
+                                                fields => [
+                                                    qw(abstract.analyzed pod.analyzed)
+                                                ],
+                                                query            => $clean,
+                                                default_operator => 'AND',
+                                                allow_leading_wildcard =>
+                                                    false,
+                                            },
+                                        },
+                                    ],
+                                }
+                            },
+                        ],
+                    }
+                },
+            ],
+        },
+    };
+
+    my $script_key = $self->es->api_version ge '5_0' ? 'source' : 'inline';
+
+    $query = {
+        function_score => {
+            script_score => {
+
+                # prefer shorter module names
+                script => {
+                    lang        => 'expression',
+                    $script_key =>
+                        "_score - (doc['documentation_length'].value == 0 ? 26 : doc['documentation_length'].value)/400",
+                },
+            },
+            query => {
+                boosting => {
+                    negative_boost => 0.5,
+                    positive       => $query,
+                    negative       => {
+                        bool => {
+                            should => [
+                                {
+                                    term => { 'mime' => 'text/x-script.perl' }
+                                },
+                                { term => { 'deprecated' => true } },
+                            ],
+                        },
+                    },
+                },
+            },
+        },
+    };
+
+    my $search = merge(
+        $params,
+        {
+            query   => $query,
+            _source => [ qw(
+                module
+                abstract
+                author
+                authorized
+                date
+                description
+                dist_fav_count
+                distribution
+                documentation
+                id
+                indexed
+                path
+                pod_lines
+                release
+                status
+            ) ],
+        }
+    );
+
+    # Ensure our requested fields are unique so that Elasticsearch doesn't
+    # return us the same value multiple times in an unexpected arrayref.
+    $search->{_source} = [ uniq @{ $search->{_source} || [] } ];
+
+    return $search;
+}
+
+sub run_query {
+    my ( $self, $doc, $es_query ) = @_;
+    return $self->es->search(
+        es_doc_path($doc),
+        body        => $es_query,
+        search_type => 'dfs_query_then_fetch',
+    );
+}
+
+sub _extract_results {
+    my ( $self, $es_results ) = @_;
+
+    return [
+        map {
+            my $res = $_;
+            +{
+                favorites => delete $res->{_source}->{dist_fav_count},
+                %{ $res->{_source} },
+                score => $res->{_score},
+            }
+        } @{ $es_results->{hits}{hits} }
+    ];
+}
+
+1;
+
diff --git a/lib/MetaCPAN/Role/Author.pm b/lib/MetaCPAN/Role/Author.pm
deleted file mode 100644
index f39028b11..000000000
--- a/lib/MetaCPAN/Role/Author.pm
+++ /dev/null
@@ -1,23 +0,0 @@
-package MetaCPAN::Role::Author;
-
-use Moose::Role;
-
-has 'author' => (
-    is         => 'ro',
-    isa        => 'MetaCPAN::Schema::Result::Zauthor',
-    lazy_build => 1,
-);
-
-
-sub _build_author {
-
-    my $self = shift;
-
-    die "no pauseid" if !$self->metadata->pauseid;
-    my $author = $self->schema->resultset( 'MetaCPAN::Schema::Result::Zauthor' )
-        ->find_or_create( { zpauseid => $self->metadata->pauseid } );
-    return $author;
-
-}
-
-1;
diff --git a/lib/MetaCPAN/Role/Common.pm b/lib/MetaCPAN/Role/Common.pm
deleted file mode 100644
index fd4c6c92a..000000000
--- a/lib/MetaCPAN/Role/Common.pm
+++ /dev/null
@@ -1,58 +0,0 @@
-package MetaCPAN::Role::Common;
-
-use Moose::Role;
-
-has 'cpan' => (
-    is         => 'rw',
-    isa        => 'Str',
-    lazy_build => 1,
-);
-
-has 'debug' => (
-    is         => 'rw',
-    lazy_build => 1,
-);
-
-has 'es' => ( is => 'rw', lazy_build => 1 );
-
-sub file2mod {
-
-    my $self        = shift;
-    my $name = shift;
-
-    $name =~ s{\Alib\/}{};
-    $name =~ s{\.(pod|pm)\z}{};
-    $name =~ s{\/}{::}gxms;
-
-    return $name;
-}
-
-sub _build_debug {
-
-    my $self = shift;
-    return $ENV{'DEBUG'} || 0;
-
-}
-
-sub _build_cpan {
-
-    my $self = shift;
-    my @dirs = ( "$ENV{'HOME'}/CPAN", "$ENV{'HOME'}/minicpan", $ENV{'MINICPAN'} );
-    foreach my $dir ( @dirs ) {
-        return $dir if -d $dir;
-    }
-    return;
-
-}
-
-sub _build_es {
-
-    my $e = ElasticSearch->new(
-        servers   => 'localhost:9200',
-        transport => 'http',         # default 'http'
-                                         #trace_calls => 'log_file',
-    );
-
-}
-
-1;
diff --git a/lib/MetaCPAN/Role/DB.pm b/lib/MetaCPAN/Role/DB.pm
deleted file mode 100644
index e8bdcee53..000000000
--- a/lib/MetaCPAN/Role/DB.pm
+++ /dev/null
@@ -1,74 +0,0 @@
-package MetaCPAN::Role::DB;
-
-use Modern::Perl;
-use Moose::Role;
-use DBI;
-use Find::Lib;
-
-has 'db_file' => (
-    is         => 'rw',
-    isa        => 'Str',
-    lazy_build => 1,
-);
-
-has 'dsn' => (
-    is         => 'rw',
-    isa        => 'Str',
-    lazy_build => 1,
-);
-
-has 'module_rs' => (
-    is      => 'rw',
-    lazy_build => 1,
-);
-
-has 'schema' => (
-    is         => 'ro',
-    lazy_build => 1,
-);
-
-has 'schema_class' => (
-    is      => 'rw',
-    default => 'MetaCPAN::Schema',
-);
-
-sub _build_dsn {
-
-    my $self = shift;
-    return "dbi:SQLite:dbname=" . $self->db_file;
-
-}
-
-sub _build_db_file {
-
-    my $self   = shift;
-    my @caller = caller();
-
-    my $db_file = Find::Lib::base() . '/' . $self->db_path;
-
-    if ( !-e $db_file ) {
-        die "$db_file not found";
-    }
-
-    return $db_file;
-
-}
-
-sub _build_module_rs {
-    
-    my $self = shift;
-    return my $rs = $self->schema->resultset( 'Module' );
-    
-}
-
-sub _build_schema {
-
-    my $self   = shift;
-    my $schema = $self->schema_class->connect( $self->dsn, '', '', '',
-        { sqlite_use_immediate_transaction => 1, AutoCommit => 1 } );
-
-    #$schema->storage->dbh->sqlite_busy_timeout(0);
-    return $schema;
-}
-
-1;
diff --git a/lib/MetaCPAN/Role/HasConfig.pm b/lib/MetaCPAN/Role/HasConfig.pm
new file mode 100644
index 000000000..ad1bae0d2
--- /dev/null
+++ b/lib/MetaCPAN/Role/HasConfig.pm
@@ -0,0 +1,25 @@
+package MetaCPAN::Role::HasConfig;
+
+use Moose::Role;
+
+use MetaCPAN::Server::Config  ();
+use MetaCPAN::Types::TypeTiny qw( HashRef );
+
+# Done like this so can be required by a role
+sub config {
+    return $_[0]->_config;
+}
+
+has _config => (
+    is      => 'ro',
+    isa     => HashRef,
+    lazy    => 1,
+    builder => '_build_config',
+);
+
+sub _build_config {
+    my $self = shift;
+    return MetaCPAN::Server::Config::config();
+}
+
+1;
diff --git a/lib/MetaCPAN/Role/HasRogueDistributions.pm b/lib/MetaCPAN/Role/HasRogueDistributions.pm
new file mode 100644
index 000000000..14a02facc
--- /dev/null
+++ b/lib/MetaCPAN/Role/HasRogueDistributions.pm
@@ -0,0 +1,25 @@
+package MetaCPAN::Role::HasRogueDistributions;
+
+use Moose::Role;
+
+use MetaCPAN::Types::TypeTiny qw( ArrayRef );
+
+has rogue_distributions => (
+    is      => 'ro',
+    isa     => ArrayRef,
+    default => sub {
+        [ qw(
+            Bundle-Everything
+            kurila
+            perl-5.005_02+apache1.3.3+modperl
+            perlbench
+            perl_debug
+            perl_mlb
+            pod2texi
+            spodcxx
+        ) ];
+    },
+);
+
+no Moose::Role;
+1;
diff --git a/lib/MetaCPAN/Role/Logger.pm b/lib/MetaCPAN/Role/Logger.pm
new file mode 100644
index 000000000..95d80d662
--- /dev/null
+++ b/lib/MetaCPAN/Role/Logger.pm
@@ -0,0 +1,73 @@
+package MetaCPAN::Role::Logger;
+
+use v5.10;
+use Moose::Role;
+
+use Log::Contextual qw( set_logger );
+use Log::Log4perl ':easy';
+use MetaCPAN::Types::TypeTiny qw( Logger Str );
+use MooseX::Getopt            ();                 ## no perlimports
+use Path::Tiny                qw( path );
+
+has level => (
+    is            => 'ro',
+    isa           => Str,
+    required      => 1,
+    trigger       => \&set_level,
+    documentation => 'Log level',
+);
+
+has logger => (
+    is       => 'ro',
+    required => 1,
+    isa      => Logger,
+    coerce   => 1,
+    traits   => ['NoGetopt'],
+);
+
+sub set_level {
+    my $self = shift;
+    $self->logger->level(
+        Log::Log4perl::Level::to_priority( uc( $self->level ) ) );
+}
+
+# NOTE: This makes the test suite print "mapping" regardless of which
+# script class is actually running (the category only gets set once)
+# but Log::Contextual gets mad if you call set_logger more than once.
+sub set_logger_once {
+    state $logger_set = 0;
+    return if $logger_set;
+
+    my $self = shift;
+
+    set_logger $self->logger;
+
+    $logger_set = 1;
+
+    return;
+}
+
+# Not actually a Moose builder, so we should probably rename it.
+sub _build_logger {
+    my ($config) = @_;
+    my $log = Log::Log4perl->get_logger( $ARGV[0]
+            || 'this_would_have_been_argv_0_but_there_is_no_such_thing' );
+    foreach my $c (@$config) {
+        my $layout = Log::Log4perl::Layout::PatternLayout->new( $c->{layout}
+                || qq{%d %p{1} %c: %m{chomp}%n} );
+
+        if ( $c->{class} =~ /Appender::File$/ && $c->{filename} ) {
+
+            # Create the log file's parent directory if necessary.
+            path( $c->{filename} )->parent->mkpath;
+        }
+
+        my $app = Log::Log4perl::Appender->new( $c->{class}, %$c );
+
+        $app->layout($layout);
+        $log->add_appender($app);
+    }
+    return $log;
+}
+
+1;
diff --git a/lib/MetaCPAN/Role/Script.pm b/lib/MetaCPAN/Role/Script.pm
new file mode 100644
index 000000000..3ed00e450
--- /dev/null
+++ b/lib/MetaCPAN/Role/Script.pm
@@ -0,0 +1,300 @@
+package MetaCPAN::Role::Script;
+
+use Moose::Role;
+
+use Carp                       ();
+use IO::Prompt::Tiny           qw( prompt );
+use Log::Contextual            qw( :log :dlog );
+use MetaCPAN::Model            ();
+use MetaCPAN::Types::TypeTiny  qw( AbsPath Bool ES HashRef Int Path Str );
+use MetaCPAN::Util             qw( root_dir );
+use Mojo::Server               ();
+use Term::ANSIColor            qw( colored );
+use MetaCPAN::Model::ESWrapper ();
+
+use MooseX::Getopt::OptionTypeMap ();
+for my $type ( Path, AbsPath, ES ) {
+    MooseX::Getopt::OptionTypeMap->add_option_type_to_map( $type, '=s' );
+}
+
+with( 'MetaCPAN::Role::HasConfig', 'MetaCPAN::Role::Fastly',
+    'MetaCPAN::Role::Logger' );
+
+has cpan => (
+    is            => 'ro',
+    isa           => Path,
+    lazy          => 1,
+    builder       => '_build_cpan',
+    coerce        => 1,
+    documentation =>
+        'Location of a local CPAN mirror, looks for $ENV{MINICPAN} and ~/CPAN',
+);
+
+has cpan_file_map => (
+    is      => 'ro',
+    isa     => HashRef,
+    lazy    => 1,
+    builder => '_build_cpan_file_map',
+    traits  => ['NoGetopt'],
+);
+
+has die_on_error => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'Die on errors instead of simply logging',
+);
+
+has exit_code => (
+    isa           => Int,
+    is            => 'rw',
+    default       => 0,
+    documentation => 'Exit Code to be returned on termination',
+);
+
+has ua => (
+    is      => 'ro',
+    lazy    => 1,
+    builder => '_build_ua',
+);
+
+has proxy => (
+    is      => 'ro',
+    isa     => Str,
+    default => '',
+);
+
+has es => (
+    is            => 'ro',
+    isa           => ES,
+    required      => 1,
+    init_arg      => 'elasticsearch_servers',
+    coerce        => 1,
+    documentation => 'Elasticsearch http connection string',
+);
+
+has model => (
+    is       => 'ro',
+    init_arg => undef,
+    lazy     => 1,
+    builder  => '_build_model',
+    traits   => ['NoGetopt'],
+);
+
+has port => (
+    isa           => Int,
+    is            => 'ro',
+    required      => 0,
+    lazy          => 1,
+    default       => sub {5000},
+    documentation => 'Port for the proxy, defaults to 5000',
+);
+
+has home => (
+    is      => 'ro',
+    isa     => Path,
+    lazy    => 1,
+    coerce  => 1,
+    default => sub { root_dir() },
+);
+
+has _minion => (
+    is      => 'ro',
+    isa     => 'Minion',
+    lazy    => 1,
+    handles => { _add_to_queue => 'enqueue', stats => 'stats', },
+    default => sub { Mojo::Server->new->build_app('MetaCPAN::API')->minion },
+);
+
+has queue => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'add indexing jobs to the minion queue',
+);
+
+sub handle_error {
+    my ( $self, $error, $die_always ) = @_;
+
+    # Die if configured (for the test suite).
+    $die_always = $self->die_on_error unless defined $die_always;
+
+    # Always log.
+    log_fatal {$error};
+
+    $! = $self->exit_code if ( $self->exit_code != 0 );
+
+    Carp::croak $error if $die_always;
+}
+
+sub print_error {
+    my ( $self, $error ) = @_;
+
+    log_error {$error};
+}
+
+sub _build_model {
+    my $self = shift;
+
+    # es provided by ElasticSearchX::Model::Role
+
+    my $es = MetaCPAN::Model::ESWrapper->new( $self->es );
+    return MetaCPAN::Model->new( es => $es );
+}
+
+sub _build_ua {
+    my $self  = shift;
+    my $ua    = LWP::UserAgent->new;
+    my $proxy = $self->proxy;
+
+    if ($proxy) {
+        $proxy eq 'env'
+            ? $ua->env_proxy
+            : $ua->proxy( [qw], $proxy );
+    }
+
+    $ua->agent('MetaCPAN');
+
+    return $ua;
+}
+
+sub _build_cpan {
+    my $self = shift;
+    my @dirs = (
+        $ENV{MINICPAN},    '/home/metacpan/CPAN',
+        "$ENV{HOME}/CPAN", "$ENV{HOME}/minicpan",
+    );
+    foreach my $dir ( grep {defined} @dirs ) {
+        return $dir if -d $dir;
+    }
+    die
+        "Couldn't find a local cpan mirror. Please specify --cpan or set MINICPAN";
+
+}
+
+sub _build_cpan_file_map {
+    my $self = shift;
+    my $ls   = $self->cpan->child(qw(indices find-ls.gz));
+    unless ( -e $ls ) {
+        die "File $ls does not exist";
+    }
+    log_info {"Reading $ls"};
+    my $cpan = {};
+    open my $fh, "<:gzip", $ls;
+    while (<$fh>) {
+        my $path = ( split(/\s+/) )[-1];
+        next unless ( $path =~ /^authors\/id\/\w+\/\w+\/(\w+)\/(.*)$/ );
+        $cpan->{$1}{$2} = 1;
+    }
+    close $fh;
+    return $cpan;
+}
+
+sub run { }
+before run => sub {
+    my $self = shift;
+    $self->set_logger_once;
+};
+
+sub are_you_sure {
+    my ( $self, $msg ) = @_;
+    my $iconfirmed = 0;
+
+    if ( -t *STDOUT ) {
+        my $answer
+            = prompt colored( ['bold red'], "*** Warning ***: $msg" ) . "\n"
+            . 'Are you sure you want to do this (type "YES" to confirm) ? ';
+        if ( $answer ne 'YES' ) {
+            log_error {"Confirmation incorrect: '$answer'"};
+            print "Operation will be interruped!\n";
+
+            #Set System Error: 125 - ECANCELED - Operation canceled
+            $self->exit_code(125);
+            $self->handle_error( 'Operation canceled on User Request', 1 );
+        }
+        else {
+            log_info {'Operation confirmed.'};
+            print "alright then...\n";
+            $iconfirmed = 1;
+        }
+    }
+    else {
+        log_info {"*** Warning ***: $msg"};
+        $iconfirmed = 1;
+    }
+
+    return $iconfirmed;
+}
+
+before perform_purges => sub {
+    my ($self) = @_;
+    if ( $self->has_surrogate_keys_to_purge ) {
+        log_info {
+            "CDN Purge: " . join ', ', $self->surrogate_keys_to_purge;
+        };
+    }
+};
+
+1;
+
+__END__
+
+=pod
+
+=head1 NAME
+
+MetaCPAN::Role::Script - Base Role which is used by many command line applications
+
+=head1 SYNOPSIS
+
+Roles which should be available to all modules.
+
+=head1 OPTIONS
+
+This Role makes the command line application accept the following options
+
+=over 4
+
+=item Option C<--await 15>
+
+This option will set the I.
+After C seconds the Application will fail with an Exception and the Exit Code [112]
+(C<112 - EHOSTDOWN - Host is down>) will be returned
+
+    bin/metacpan  --await 15
+
+B If the I service does not become available
+within C seconds it exits the Script with Exit Code C< 112 >.
+
+See L>
+
+=back
+
+=head1 METHODS
+
+This Role provides the following methods
+
+=over 4
+
+=item C
+
+Requests the user to confirm the operation with "I< YES >"
+
+B When the operator input does not match "I< YES >" it will exit the Script
+with Exit Code [125] (C<125 - ECANCELED - Operation canceled>).
+
+=item C
+
+Logs the string C with the log function as fatal error.
+If C is not equel C< 0 > sets its value in C< $! >.
+If the option C<--die_on_error> is enabled it throws an Exception with C.
+If the parameter C is set it overrides the option C<--die_on_error>.
+
+=item C
+
+Logs the string C with the log function and displays it in red.
+But it does not end the application.
+
+=back
+
+=cut
diff --git a/lib/MetaCPAN/Schema.pm b/lib/MetaCPAN/Schema.pm
deleted file mode 100644
index 2daa46d55..000000000
--- a/lib/MetaCPAN/Schema.pm
+++ /dev/null
@@ -1,12 +0,0 @@
-package MetaCPAN::Schema;
-use base qw/DBIx::Class::Schema::Loader/;
-
-__PACKAGE__->loader_options(
-#    constraint              => '^foo.*',
-    debug                   => 0,
-);
-
-__PACKAGE__->naming('current');
-__PACKAGE__->use_namespaces(1);
-
-1;
diff --git a/lib/MetaCPAN/Script/Author.pm b/lib/MetaCPAN/Script/Author.pm
new file mode 100644
index 000000000..00e59e372
--- /dev/null
+++ b/lib/MetaCPAN/Script/Author.pm
@@ -0,0 +1,345 @@
+package MetaCPAN::Script::Author;
+
+use strict;
+use warnings;
+
+use Moose;
+with 'MooseX::Getopt', 'MetaCPAN::Role::Script';
+
+use Cpanel::JSON::XS           qw( decode_json );
+use DateTime                   ();
+use Email::Valid               ();
+use Encode                     ();
+use Log::Contextual            qw( :log :dlog );
+use MetaCPAN::Document::Author ();
+use MetaCPAN::ESConfig         qw( es_doc_path );
+use MetaCPAN::Types::TypeTiny  qw( Str );
+use MetaCPAN::Util             qw(diff_struct true false);
+use URI                        ();
+use XML::XPath                 ();
+
+=head1 SYNOPSIS
+
+Loads author info into db. Requires the presence of a local CPAN/minicpan.
+
+=cut
+
+has author_fh => (
+    is      => 'ro',
+    traits  => ['NoGetopt'],
+    lazy    => 1,
+    default => sub { shift->cpan . '/authors/00whois.xml' },
+);
+
+has pauseid => (
+    is  => 'ro',
+    isa => Str,
+);
+
+sub run {
+    my $self = shift;
+
+    $self->index_authors;
+    $self->es->indices->refresh;
+}
+
+my @author_config_fields = qw(
+    name
+    asciiname
+    profile
+    blog
+    perlmongers
+    donation
+    email
+    website
+    city
+    region
+    country
+    location
+    extra
+);
+
+my @cpan_fields = qw(
+    pauseid
+    name
+    email
+    website
+    asciiname
+    is_pause_custodial_account
+);
+
+my @compare_fields = do {
+    my %seen;
+    sort grep !$seen{$_}++, @cpan_fields, @author_config_fields;
+};
+
+has whois_data => (
+    is      => 'ro',
+    traits  => ['NoGetopt'],
+    lazy    => 1,
+    builder => '_build_whois_data',
+);
+
+sub _build_whois_data {
+    my $self = shift;
+
+    my $whois_data = {};
+
+    my $xp = XML::XPath->new( filename => $self->author_fh );
+
+    for my $author ( $xp->find('/cpan-whois/cpanid')->get_nodelist ) {
+        my $data = {
+            map +( $_->getLocalName, $_->string_value ),
+            grep $_->isa('XML::XPath::Node::Element'),
+            $author->getChildNodes
+        };
+
+        my $pauseid  = $data->{id};
+        my $existing = $whois_data->{$pauseid};
+        if (  !$existing
+            || $existing->{type} eq 'author' && $data->{type} eq 'list' )
+        {
+            $whois_data->{$pauseid} = $data;
+        }
+    }
+
+    return $whois_data;
+}
+
+sub index_authors {
+    my $self    = shift;
+    my $authors = $self->whois_data;
+
+    if ( $self->pauseid ) {
+        log_info {"Indexing 1 author"};
+        $authors = { $self->pauseid => $authors->{ $self->pauseid } };
+    }
+    else {
+        my $count = keys %$authors;
+        log_debug {"Counting author"};
+        log_info {"Indexing $count authors"};
+    }
+
+    my @author_ids_to_purge;
+
+    my $bulk = $self->es->bulk_helper(
+        es_doc_path('author'),
+        max_count => 250,
+        timeout   => '25m',
+    );
+
+    my $scroll = $self->es->scroll_helper(
+        es_doc_path('author'),
+        size => 500,
+        body => {
+            query => {
+                $self->pauseid
+                ? (
+                    term => {
+                        pauseid => $self->pauseid,
+                    },
+                    )
+                : ( match_all => {} ),
+            },
+            _source => [@compare_fields],
+            sort    => '_doc',
+        },
+    );
+
+    # update authors
+    while ( my $doc = $scroll->next ) {
+        my $pauseid    = $doc->{_id};
+        my $whois_data = delete $authors->{$pauseid} || next;
+        $self->update_author( $bulk, $pauseid, $whois_data, $doc->{_source} );
+    }
+
+    # new authors
+    for my $pauseid ( keys %$authors ) {
+        my $whois_data = delete $authors->{$pauseid} || next;
+        $self->update_author( $bulk, $pauseid, $whois_data );
+    }
+
+    $bulk->flush;
+    $self->es->indices->refresh;
+
+    $self->perform_purges;
+
+    log_info {"done"};
+}
+
+sub author_data_from_cpan {
+    my $self = shift;
+    my ( $pauseid, $whois_data ) = @_;
+
+    my $author_config = $self->author_config($pauseid) || {};
+
+    my $data = {
+        pauseid   => $pauseid,
+        name      => $whois_data->{fullname},
+        email     => $whois_data->{email},
+        website   => $whois_data->{homepage},
+        asciiname => $whois_data->{asciiname},
+        %$author_config,
+        is_pause_custodial_account => (
+            ( $whois_data->{fullname} // '' )
+            =~ /\(PAUSE Custodial Account\)/ ? true : false
+        ),
+    };
+
+    undef $data->{name}
+        if ref $data->{name};
+
+    if ( !length $data->{name} ) {
+        $data->{name} = $pauseid;
+    }
+
+    $data->{asciiname} = q{}
+        if !defined $data->{asciiname};
+
+    $data->{email} = lc($pauseid) . '@cpan.org'
+        unless $data->{email} && Email::Valid->address( $data->{email} );
+
+    $data->{website} = [
+
+        # normalize www.homepage.com to http://www.homepage.com
+        map +( $_->scheme ? '' : 'http://' ) . $_->as_string,
+        map URI->new($_)->canonical,
+        grep $_,
+        map +( ref eq 'ARRAY' ? @$_ : $_ ),
+        $data->{website}
+    ];
+
+    # Do not import lat / lon's in the wrong order, or just invalid
+    if ( my $loc = $data->{location} ) {
+        if ( ref $loc ne 'ARRAY' || @$loc != 2 ) {
+            delete $data->{location};
+        }
+        else {
+            my $lat = $loc->[1];
+            my $lon = $loc->[0];
+
+            if ( !defined $lat or $lat > 90 or $lat < -90 ) {
+
+                # Invalid latitude
+                delete $data->{location};
+            }
+            elsif ( !defined $lon or $lon > 180 or $lon < -180 ) {
+
+                # Invalid longitude
+                delete $data->{location};
+            }
+        }
+    }
+
+    return $data;
+}
+
+sub update_author {
+    my $self = shift;
+    my ( $bulk, $pauseid, $whois_data, $current_data ) = @_;
+
+    my $data = $self->author_data_from_cpan( $pauseid, $whois_data );
+
+    log_debug {
+        Encode::encode_utf8( sprintf(
+            "Indexing %s: %s <%s>",
+            $pauseid, $data->{name}, $data->{email}
+        ) );
+    };
+
+    # Now check the format we have is actually correct
+    if ( my @errors = MetaCPAN::Document::Author->validate($data) ) {
+        Dlog_error {
+            "Invalid data for $pauseid: $_"
+        }
+        \@errors;
+        return;
+    }
+
+    if ( my $diff = diff_struct( $current_data, $data, 1 ) ) {
+
+        # log a sampling of differences
+        if ( $self->has_surrogate_keys_to_purge % 10 == 9 ) {
+            Dlog_debug {
+                "Found difference in $pauseid: $_"
+            }
+            $diff;
+        }
+    }
+    else {
+        return;
+    }
+
+    $data->{updated} = DateTime->now( time_zone => 'UTC' )->iso8601;
+
+    $bulk->update( {
+        id            => $pauseid,
+        doc           => $data,
+        doc_as_upsert => true,
+    } );
+
+    $self->purge_author_key($pauseid);
+}
+
+sub author_config {
+    my ( $self, $pauseid ) = @_;
+
+    my $dir = $self->cpan->child( 'authors',
+        MetaCPAN::Util::author_dir($pauseid) );
+
+    return undef
+        unless $dir->is_dir;
+
+    my $author_cpan_files = $self->cpan_file_map->{$pauseid}
+        or return undef;
+
+    # Get the most recent version
+    my ($file) = map $_->[0], sort { $b->[1] <=> $a->[1] }
+        map [ $_ => $_->stat->mtime ],
+        grep $author_cpan_files->{ $_->basename },
+        $dir->children(qr/\Aauthor-.*\.json\z/);
+
+    return undef
+        unless $file;
+
+    my $author;
+    eval {
+        $author = decode_json( $file->slurp_raw );
+        1;
+    } or do {
+        log_warn {"$file is broken: $@"};
+        return undef;
+    };
+
+    return {
+        map {
+            my $value = $author->{$_};
+            defined $value ? ( $_ => $value ) : ()
+        } @author_config_fields
+    };
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
+
+=pod
+
+=head1 SYNOPSIS
+
+Parse out CPAN author info, add custom per-author metadata and add it to the
+ElasticSearch index
+
+    my $author = MetaCPAN::Script::Author->new;
+    my $result = $author->index_authors;
+
+=head2 author_config( $pauseid, $dir )
+
+Returns custom author metadata if any exists.
+
+    my $conf = $author->author_config( 'OALDERS', 'O/OA/OALDERS' )
+
+=head2 index_authors
+
+Adds/updates all authors in the CPAN index to ElasticSearch.
+
+=cut
diff --git a/lib/MetaCPAN/Script/Backpan.pm b/lib/MetaCPAN/Script/Backpan.pm
new file mode 100644
index 000000000..935ed4a36
--- /dev/null
+++ b/lib/MetaCPAN/Script/Backpan.pm
@@ -0,0 +1,208 @@
+package MetaCPAN::Script::Backpan;
+
+use strict;
+use warnings;
+
+use Moose;
+
+use Log::Contextual           qw( :log :dlog );
+use MetaCPAN::ESConfig        qw( es_doc_path );
+use MetaCPAN::Types::TypeTiny qw( Bool HashRef Str );
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt::Dashes';
+
+has distribution => (
+    is            => 'ro',
+    isa           => Str,
+    documentation => 'work on given distribution',
+);
+
+has undo => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'mark releases as status=cpan',
+);
+
+has files_only => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'only update the "file" index',
+);
+
+has _release_status => (
+    is      => 'ro',
+    isa     => HashRef,
+    default => sub { +{} },
+);
+
+has _bulk => (
+    is      => 'ro',
+    isa     => HashRef,
+    default => sub { +{} },
+);
+
+sub run {
+    my $self = shift;
+
+    $self->es->trace_calls(1) if $ENV{DEBUG};
+
+    $self->build_release_status_map();
+
+    $self->update_releases() unless $self->files_only;
+
+    $self->update_files();
+
+    $_->flush for values %{ $self->_bulk };
+}
+
+sub build_release_status_map {
+    my $self = shift;
+
+    log_info {"find_releases"};
+
+    my $scroll = $self->es->scroll_helper(
+        scroll => '5m',
+        es_doc_path('release'),
+        body => {
+            %{ $self->_get_release_query },
+            size    => 500,
+            _source => [ 'author', 'archive', 'name' ],
+        },
+    );
+
+    while ( my $release = $scroll->next ) {
+        my $author  = $release->{_source}{author};
+        my $archive = $release->{_source}{archive};
+        my $name    = $release->{_source}{name};
+        next unless $name;    # bypass some broken releases
+
+        $self->_release_status->{$author}{$name} = [
+            (
+                $self->undo
+                    or exists $self->cpan_file_map->{$author}{$archive}
+                )
+            ? 'cpan'
+            : 'backpan',
+            $release->{_id}
+        ];
+    }
+}
+
+sub _get_release_query {
+    my $self = shift;
+
+    unless ( $self->undo ) {
+        return +{
+            query => {
+                bool =>
+                    { must_not => [ { term => { status => 'backpan' } } ] }
+            }
+        };
+    }
+
+    return +{
+        query => {
+            bool => {
+                must => [
+                    { term => { status => 'backpan' } },
+                    (
+                        $self->distribution
+                        ? {
+                            term => { distribution => $self->distribution }
+                            }
+                        : ()
+                    )
+                ]
+            }
+        }
+    };
+}
+
+sub update_releases {
+    my $self = shift;
+
+    log_info {"update_releases"};
+
+    $self->_bulk->{release} ||= $self->es->bulk_helper(
+        es_doc_path('release'),
+        max_count => 250,
+        timeout   => '5m',
+    );
+
+    for my $author ( keys %{ $self->_release_status } ) {
+
+        # value = [ status, _id ]
+        for ( values %{ $self->_release_status->{$author} } ) {
+            $self->_bulk->{release}->update( {
+                id  => $_->[1],
+                doc => {
+                    status => $_->[0],
+                }
+            } );
+        }
+    }
+}
+
+sub update_files {
+    my $self = shift;
+
+    for my $author ( keys %{ $self->_release_status } ) {
+        my @releases = keys %{ $self->_release_status->{$author} };
+        while ( my @chunk = splice @releases, 0, 1000 ) {
+            $self->update_files_author( $author, \@chunk );
+        }
+    }
+}
+
+sub update_files_author {
+    my $self            = shift;
+    my $author          = shift;
+    my $author_releases = shift;
+
+    log_info { "update_files: " . $author };
+
+    my $scroll = $self->es->scroll_helper(
+        scroll => '5m',
+        es_doc_path('file'),
+        body => {
+            query => {
+                bool => {
+                    must => [
+                        { term  => { author  => $author } },
+                        { terms => { release => $author_releases } }
+                    ]
+                }
+            },
+            size    => 500,
+            _source => ['release'],
+        },
+    );
+
+    $self->_bulk->{file} ||= $self->es->bulk_helper(
+        es_doc_path('file'),
+        max_count => 250,
+        timeout   => '5m',
+    );
+    my $bulk = $self->_bulk->{file};
+
+    while ( my $file = $scroll->next ) {
+        my $release = $file->{_source}{release};
+        $bulk->update( {
+            id  => $file->{_id},
+            doc => {
+                status => $self->_release_status->{$author}{$release}[0]
+            }
+        } );
+    }
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
+
+=pod
+
+Sets "backpan" status on all BackPAN releases.
+
+=cut
diff --git a/lib/MetaCPAN/Script/Backup.pm b/lib/MetaCPAN/Script/Backup.pm
new file mode 100644
index 000000000..2ced44ea0
--- /dev/null
+++ b/lib/MetaCPAN/Script/Backup.pm
@@ -0,0 +1,254 @@
+package MetaCPAN::Script::Backup;
+
+use strict;
+use warnings;
+use feature qw( state );
+
+use Cpanel::JSON::XS          qw( decode_json encode_json );
+use DateTime                  ();
+use IO::Zlib                  ();
+use Log::Contextual           qw( :log :dlog );
+use MetaCPAN::Types::TypeTiny qw( Bool CommaSepOption Int Path Str );
+use MetaCPAN::Util            qw( true false );
+use MetaCPAN::ESConfig        qw( es_config );
+use Moose;
+use Try::Tiny qw( catch try );
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt::Dashes';
+
+has batch_size => (
+    is            => 'ro',
+    isa           => Int,
+    default       => 100,
+    documentation =>
+        'Number of documents to restore in one batch, defaults to 100',
+);
+
+has index => (
+    reader        => '_index',
+    is            => 'ro',
+    isa           => CommaSepOption,
+    coerce        => 1,
+    default       => sub { es_config->all_indexes },
+    documentation => 'ES indexes to backup, defaults to "'
+        . join( ', ', @{ es_config->all_indexes } ) . '"',
+);
+
+has type => (
+    is            => 'ro',
+    isa           => Str,
+    documentation => 'ES type do backup, optional',
+);
+
+has size => (
+    is            => 'ro',
+    isa           => Int,
+    default       => 1000,
+    documentation => 'Size of documents to fetch at once, defaults to 1000',
+);
+
+has purge => (
+    is            => 'ro',
+    isa           => Bool,
+    documentation => 'Purge old backups',
+);
+
+has dry_run => (
+    is            => 'ro',
+    isa           => Bool,
+    documentation => q{Don't actually purge old backups},
+);
+
+has restore => (
+    is            => 'ro',
+    isa           => Path,
+    coerce        => 1,
+    documentation => 'Restore a backup',
+);
+
+sub run {
+    my $self = shift;
+
+    return $self->run_purge   if $self->purge;
+    return $self->run_restore if $self->restore;
+
+    my $es = $self->es;
+
+    for my $index ( @{ $self->_index } ) {
+
+        $self->es->indices->refresh( index => $index );
+
+        my $filename = join( '-',
+            DateTime->now->strftime('%F'),
+            grep {defined} $index,
+            $self->type );
+
+        my $file = $self->home->child( qw(var backup), "$filename.json.gz" );
+        $file->parent->mkpath unless ( -e $file->parent );
+        my $fh = IO::Zlib->new( "$file", 'wb4' );
+
+        my $scroll = $es->scroll_helper(
+            index => $index,
+            $self->type ? ( type => $self->type ) : (),
+            scroll => '1m',
+            body   => {
+                _source => true,
+                size    => $self->size,
+                sort    => '_doc',
+            },
+        );
+
+        log_info { 'Backing up ', $scroll->total, ' documents' };
+
+        while ( my $result = $scroll->next ) {
+            print $fh encode_json($result), $/;
+        }
+        close $fh;
+    }
+    log_info {'done'};
+}
+
+sub run_restore {
+    my $self = shift;
+
+    return log_fatal { $self->restore, q{ doesn't exist} }
+        unless ( -e $self->restore );
+    log_info { 'Restoring from ', $self->restore };
+
+    my @bulk;
+    my $es = $self->es;
+    my $fh = IO::Zlib->new( $self->restore->stringify, 'rb' );
+
+    my %bulk_store;
+
+    while ( my $line = $fh->readline ) {
+
+        state $line_count = 0;
+        ++$line_count;
+        my $raw;
+
+        try { $raw = decode_json($line) }
+        catch {
+            log_warn {"cannot decode JSON: $line --- $&"};
+        };
+
+        # Create our bulk_helper if we need,
+        # incase a backup has mixed _index or _type
+        # create a new bulk helper for each
+        my $bulk_key = $raw->{_index} . $raw->{_type};
+
+        $bulk_store{$bulk_key} ||= $es->bulk_helper(
+            index     => $raw->{_index},
+            type      => $raw->{_type},
+            max_count => $self->batch_size
+        );
+
+        # Fetch relevant bulk helper
+        my $bulk = $bulk_store{$bulk_key};
+
+        my $parent = $raw->{_parent};
+
+        if ( $raw->{_type} eq 'author' ) {
+
+            # Hack for dodgy lat / lon's
+            if ( my $loc = $raw->{_source}->{location} ) {
+
+                my $lat = $loc->[1];
+                my $lon = $loc->[0];
+
+                if ( $lat > 90 or $lat < -90 ) {
+
+                    # Invalid latitude
+                    delete $raw->{_source}->{location};
+                }
+                elsif ( $lon > 180 or $lon < -180 ) {
+
+                    # Invalid longitude
+                    delete $raw->{_source}->{location};
+                }
+            }
+        }
+
+        my $exists = $es->exists(
+            index => $raw->{_index},
+            type  => $raw->{_type},
+            id    => $raw->{_id},
+        );
+
+        if ($exists) {
+            $bulk->update( {
+                id            => $raw->{_id},
+                doc           => $raw->{_source},
+                doc_as_upsert => true,
+            } );
+
+        }
+        else {
+            $bulk->create( {
+                id => $raw->{_id},
+                $parent ? ( parent => $parent ) : (),
+                source => $raw->{_source},
+            } );
+        }
+    }
+
+    # Flush anything left over just incase
+    for my $bulk ( values %bulk_store ) {
+        $bulk->flush;
+    }
+
+    log_info {'done'};
+}
+
+sub run_purge {
+    my $self = shift;
+
+    my $now = DateTime->now;
+    $self->home->child(qw(var backup))->visit(
+        sub {
+            my $file = shift;
+            return if ( $file->is_dir );
+
+            my $mtime = DateTime->from_epoch( epoch => $file->stat->mtime );
+
+            # keep a daily backup for one week
+            return
+                if ( $mtime > $now->clone->subtract( days => 7 ) );
+
+            # after that keep weekly backups
+            if ( $mtime->clone->truncate( to => 'week' )
+                != $mtime->clone->truncate( to => 'day' ) )
+            {
+                log_info {"Removing old backup $file"};
+                return log_info {'Not (dry run)'}
+                if ( $self->dry_run );
+                $file->remove;
+            }
+        },
+        { recurse => 1 }
+    );
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
+
+__END__
+
+=head1 NAME
+
+MetaCPAN::Script::Backup - Backup indices and types
+
+=head1 SYNOPSIS
+
+ $ bin/metacpan backup --index user --type account
+
+ $ bin/metacpan backup --purge
+
+=head1 DESCRIPTION
+
+Creates C<.json.gz> files in C. These files contain
+one record per line.
+
+=head2 purge
+
+Purges old backups. Backups from the current week are kept.
diff --git a/lib/MetaCPAN/Script/CPANTesters.pm b/lib/MetaCPAN/Script/CPANTesters.pm
new file mode 100644
index 000000000..ebf630ae8
--- /dev/null
+++ b/lib/MetaCPAN/Script/CPANTesters.pm
@@ -0,0 +1,185 @@
+package MetaCPAN::Script::CPANTesters;
+
+use Moose;
+
+use DBI                                    ();
+use ElasticSearchX::Model::Document::Types qw( ESBulk );
+use File::stat                             qw( stat );
+use IO::Uncompress::Bunzip2                qw( bunzip2 );
+use Log::Contextual                        qw( :log :dlog );
+use MetaCPAN::ESConfig                     qw( es_doc_path );
+use MetaCPAN::Types::TypeTiny              qw( Bool Path Uri );
+use MetaCPAN::Util                         qw( true false );
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt::Dashes';
+
+has db => (
+    is      => 'ro',
+    isa     => Uri,
+    lazy    => 1,
+    coerce  => 1,
+    builder => '_build_db',
+);
+
+has force_refresh => (
+    is      => 'ro',
+    isa     => Bool,
+    default => 0,
+);
+
+# XXX move path to config
+has mirror_file => (
+    is      => 'ro',
+    isa     => Path,
+    default => sub {
+        shift->home->child( 'var', ( $ENV{HARNESS_ACTIVE} ? 't' : () ),
+            'tmp', 'cpantesters.db' );
+    },
+    coerce => 1,
+);
+
+has skip_download => (
+    is  => 'ro',
+    isa => Bool,
+);
+
+has _bulk => (
+    is      => 'ro',
+    isa     => ESBulk,
+    lazy    => 1,
+    default => sub {
+        $_[0]->es->bulk_helper( es_doc_path('release') );
+    },
+);
+
+# XXX fix hardcoded path
+sub _build_db {
+    my $self = shift;
+    return $ENV{HARNESS_ACTIVE}
+        ? $self->home->child('t/var/cpantesters-release-fake.db.bz2')
+        : 'http://devel.cpantesters.org/release/release.db.bz2';
+}
+
+sub run {
+    my $self = shift;
+    $self->index_reports;
+    $self->es->indices->refresh;
+}
+
+sub index_reports {
+    my $self = shift;
+
+    my $es = $self->es;
+
+    log_info { 'Mirroring ' . $self->db };
+    my $db = $self->mirror_file;
+
+    $self->ua->mirror( $self->db, "$db.bz2" ) unless $self->skip_download;
+
+    if ( -e $db && stat($db)->mtime >= stat("$db.bz2")->mtime ) {
+        log_info {'DB hasn\'t been modified'};
+        return unless $self->force_refresh;
+    }
+
+    bunzip2 "$db.bz2" => "$db", AutoClose => 1 if -e "$db.bz2";
+
+    my $scroll = $es->scroll_helper(
+        es_doc_path('release'),
+        size => '500',
+        body => {
+            sort => '_doc',
+        },
+    );
+
+    my %releases;
+    while ( my $release = $scroll->next ) {
+        my $data = $release->{_source};
+
+        # XXX temporary hack.  This may be masking issues with release
+        # versions. (Olaf)
+        my $version = $data->{version};
+        $version =~ s{\Av}{} if $version;
+
+        $releases{
+            join( '-', grep {defined} $data->{distribution}, $version ) }
+            = $data;
+    }
+
+    log_info { 'Opening database file at ' . $db };
+
+    my $dbh = DBI->connect( 'dbi:SQLite:dbname=' . $db );
+    my $sth;
+    $sth = $dbh->prepare('SELECT * FROM release');
+
+    $sth->execute;
+    my @bulk;
+    while ( my $row_from_db = $sth->fetchrow_hashref ) {
+
+       # The testers db seems to return q{} where we would expect a version of
+       # 0.
+
+        my $version = $row_from_db->{version} || 0;
+
+        # weblint++ gets a name of 'weblint' and a version of '++-1.15' from
+        # the testers db.  Special case it for now.  Maybe try and get the db
+        # fixed.
+
+        $version =~ s{\+}{}g;
+        $version =~ s{\A-}{};
+
+        my $release     = join( '-', $row_from_db->{dist}, $version );
+        my $release_doc = $releases{$release};
+
+        # there's a cpantesters dist we haven't indexed
+        next unless ($release_doc);
+
+        my $insert_ok = 0;
+
+        my $tester_results = $release_doc->{tests};
+        if ( !$tester_results ) {
+            $tester_results = {};
+            $insert_ok      = 1;
+        }
+
+        # maybe use Data::Compare instead
+        for my $condition (qw(fail pass na unknown)) {
+            last if $insert_ok;
+            if ( ( $tester_results->{$condition} || 0 )
+                != $row_from_db->{$condition} )
+            {
+                $insert_ok = 1;
+            }
+        }
+
+        next unless ($insert_ok);
+        my %tests = map { $_ => $row_from_db->{$_} } qw(fail pass na unknown);
+        $self->_bulk->update( {
+            doc           => { tests => \%tests },
+            doc_as_upsert => true,
+            id            => $release_doc->{id},
+        } );
+    }
+    $self->_bulk->flush;
+    log_info {'done'};
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
+
+=pod
+
+=head1 SYNOPSIS
+
+ $ bin/metacpan cpantesters
+
+=head1 DESCRIPTION
+
+Index CPAN Testers test results.
+
+=head1 ARGUMENTS
+
+=head2 db
+
+Defaults to C.
+
+=cut
diff --git a/lib/MetaCPAN/Script/CPANTestersAPI.pm b/lib/MetaCPAN/Script/CPANTestersAPI.pm
new file mode 100644
index 000000000..0c9e6ca50
--- /dev/null
+++ b/lib/MetaCPAN/Script/CPANTestersAPI.pm
@@ -0,0 +1,139 @@
+package MetaCPAN::Script::CPANTestersAPI;
+
+use strict;
+use warnings;
+
+use Cpanel::JSON::XS                       qw( decode_json );
+use ElasticSearchX::Model::Document::Types qw( ESBulk );
+use Log::Contextual                        qw( :log :dlog );
+use MetaCPAN::ESConfig                     qw( es_doc_path );
+use MetaCPAN::Types::TypeTiny              qw( Uri );
+use MetaCPAN::Util                         qw( true false );
+use Moose;
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt::Dashes';
+
+has url => (
+    is      => 'ro',
+    isa     => Uri,
+    coerce  => 1,
+    lazy    => 1,
+    builder => '_build_url',
+);
+
+sub _build_url {
+    my ($self) = @_;
+    $ENV{HARNESS_ACTIVE}
+        ? 'file:'
+        . $self->home->child('t/var/cpantesters-release-api-fake.json')
+        : 'http://api-3.cpantesters.org/v3/release';
+}
+
+has _bulk => (
+    is      => 'ro',
+    isa     => ESBulk,
+    lazy    => 1,
+    default => sub {
+        $_[0]->es->bulk_helper(
+            es_doc_path('release'),
+            max_count => 250,
+            timeout   => '30m',
+        );
+    },
+);
+
+sub run {
+    my $self = shift;
+    $self->index_reports;
+    $self->es->indices->refresh;
+}
+
+sub index_reports {
+    my $self = shift;
+
+    my $es = $self->es;
+
+    log_info { 'Fetching ' . $self->url };
+
+    my $res;
+    eval { $res = $self->ua->get( $self->url ) };
+    return unless $res and $res->code == 200;
+
+    my $json = $res->decoded_content;
+    my $data = decode_json $json;
+
+    my $scroll = $es->scroll_helper(
+        es_doc_path('release'),
+        size => '500',
+        body => {
+            sort => '_doc',
+        },
+    );
+
+    # Create a cache of all releases (dist + version combos)
+    my %releases;
+    while ( my $release = $scroll->next ) {
+        my $data = $release->{_source};
+
+        # XXX temporary hack.  This may be masking issues with release
+        # versions. (Olaf)
+        my $version = $data->{version};
+        $version =~ s{\Av}{} if $version;
+
+        $releases{
+            join( '-', grep {defined} $data->{distribution}, $version ) }
+            = $data;
+    }
+
+    for my $row (@$data) {
+
+        # The testers db seems to return q{} where we would expect
+        # a version of 0.
+        my $version = $row->{version} || 0;
+
+        # weblint++ gets a name of 'weblint' and a version of '++-1.15'
+        # from the testers db.  Special case it for now.  Maybe try and
+        # get the db fixed.
+
+        $version =~ s{\+}{}g;
+        $version =~ s{\A-}{};
+
+        my $release     = join( '-', $row->{dist}, $version );
+        my $release_doc = $releases{$release};
+
+        # there's a cpantesters dist we haven't indexed
+        next unless $release_doc;
+
+        # Check if we need to update this data
+        my $insert_ok      = 0;
+        my $tester_results = $release_doc->{tests};
+        if ( !$tester_results ) {
+            $tester_results = {};
+            $insert_ok      = 1;
+        }
+
+        # maybe use Data::Compare instead
+        for my $condition (qw(fail pass na unknown)) {
+            last if $insert_ok;
+            if (
+                ( $tester_results->{$condition} || 0 ) != $row->{$condition} )
+            {
+                $insert_ok = 1;
+            }
+        }
+
+        next unless $insert_ok;
+
+        my %tests = map { $_ => $row->{$_} } qw(fail pass na unknown);
+        $self->_bulk->update( {
+            doc           => { tests => \%tests },
+            doc_as_upsert => true,
+            id            => $release_doc->{id},
+        } );
+    }
+
+    $self->_bulk->flush;
+    log_info {'done'};
+}
+
+1;
diff --git a/lib/MetaCPAN/Script/CVE.pm b/lib/MetaCPAN/Script/CVE.pm
new file mode 100644
index 000000000..2c22c58f0
--- /dev/null
+++ b/lib/MetaCPAN/Script/CVE.pm
@@ -0,0 +1,261 @@
+package MetaCPAN::Script::CVE;
+
+use Moose;
+use namespace::autoclean;
+
+use Cpanel::JSON::XS          qw( decode_json );
+use Log::Contextual           qw( :log :dlog );
+use MetaCPAN::ESConfig        qw( es_doc_path );
+use MetaCPAN::Types::TypeTiny qw( Bool Str Uri );
+use MetaCPAN::Util            qw( hit_total numify_version true false );
+use Path::Tiny                qw( path );
+use Ref::Util                 qw( is_arrayref );
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt';
+
+has cve_url => (
+    is      => 'ro',
+    isa     => Uri,
+    coerce  => 1,
+    default => 'https://cpan-security.github.io/cpansa-feed/cpansa.json',
+);
+
+has cve_dev_url => (
+    is      => 'ro',
+    isa     => Uri,
+    coerce  => 1,
+    default => 'https://cpan-security.github.io/cpansa-feed/cpansa_dev.json',
+);
+
+has test => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'Test mode (pulls smaller development data set)',
+);
+
+has json_file => (
+    is            => 'ro',
+    isa           => Str,
+    default       => 0,
+    documentation =>
+        'Path to JSON file to be read instead of URL (for testing)',
+);
+
+my %range_ops = qw(< lt <= lte > gt >= gte);
+
+my %valid_keys = map { $_ => 1 } qw<
+    affected_versions
+    cpansa_id
+    cves
+    description
+    distribution
+    references
+    releases
+    reported
+    severity
+    versions
+>;
+
+sub run {
+    my $self = shift;
+    my $data = $self->retrieve_cve_data;
+    $self->index_cve_data($data);
+    return 1;
+}
+
+sub index_cve_data {
+    my ( $self, $data ) = @_;
+
+    my $bulk = $self->es->bulk_helper( es_doc_path('cve') );
+
+    log_info {'Updating the cve index'};
+
+    for my $dist ( sort keys %{$data} ) {
+        for my $cpansa ( @{ $data->{$dist} } ) {
+            if ( !$cpansa->{cpansa_id} ) {
+                log_warn { sprintf( "Dist '%s' missing cpansa_id", $dist ) };
+                next;
+            }
+
+            my @matches;
+
+            if ( !is_arrayref( $cpansa->{affected_versions} ) ) {
+                log_debug {
+                    sprintf( "Dist '%s' has non-array affected_versions %s",
+                        $dist, $cpansa->{affected_versions} )
+                };
+
+                # Temp - remove after fixed upstream
+                # (affected_versions will always be an array)
+                $cpansa->{affected_versions}
+                    = [ $cpansa->{affected_versions} ];
+
+                # next;
+            }
+
+            my @filters;
+            my @afv_filters;
+
+            for my $afv ( @{ $cpansa->{affected_versions} } ) {
+
+                # Temp - remove after fixed upstream
+                # (affected_versions will always be an array)
+                next unless $afv;
+
+                my @rules = map {s/\(.*?\)//gr} split /,/, $afv;
+
+                my @rule_filters;
+
+                for my $rule (@rules) {
+                    my ( $op, $num ) = $rule =~ /^([=<>]*)(.*)$/;
+                    $num = numify_version($num);
+
+                    if ( !$op ) {
+                        log_debug {
+                            sprintf(
+                                "Dist '%s' - affected_versions has no operator",
+                                $dist )
+                        };
+
+                        # Temp - remove after fixed upstream
+                        # (affected_versions will always have an operator)
+                        $op ||= '=';
+                    }
+
+                    if ( exists $range_ops{$op} ) {
+                        push @rule_filters,
+                            +{
+                            range => {
+                                version_numified =>
+                                    { $range_ops{$op} => $num }
+                            }
+                            };
+                    }
+                    else {
+                        push @rule_filters,
+                            +{ term => { version_numified => $num } };
+                    }
+                }
+
+                # multiple rules (csv) in affected_version line -> AND
+                if ( @rule_filters == 1 ) {
+                    push @afv_filters, @rule_filters;
+                }
+                elsif ( @rule_filters > 1 ) {
+                    push @afv_filters, { bool => { must => \@rule_filters } };
+                }
+            }
+
+            # multiple elements in affected_version -> OR
+            if ( @afv_filters == 1 ) {
+                push @filters, @afv_filters;
+            }
+            elsif ( @afv_filters > 1 ) {
+                push @filters, { bool => { should => \@afv_filters } };
+            }
+
+            if (@filters) {
+                my $query = {};
+
+                my $releases = $self->es->search(
+                    es_doc_path('release'),
+                    body => {
+                        query => {
+                            bool => {
+                                must => [
+                                    { term => { distribution => $dist } },
+                                    @filters,
+                                ]
+                            }
+                        },
+                        _source => [ "version", "name", "author", ],
+                        size    => 2000,
+                    },
+                );
+
+                if ( hit_total($releases) ) {
+                    ## no critic (ControlStructures::ProhibitMutatingListFunctions)
+                    @matches = map { $_->[0] }
+                        sort { $a->[1] <=> $b->[1] }
+                        map {
+                        [
+                            $_->{_source},
+                            numify_version( $_->{_source}{version} )
+                        ];
+                        } @{ $releases->{hits}{hits} };
+                }
+                else {
+                    log_debug {
+                        sprintf( "Dist '%s' doesn't have matches.", $dist )
+                    };
+                    next;
+                }
+            }
+
+            my $doc_data = {
+                distribution      => $dist,
+                cpansa_id         => $cpansa->{cpansa_id},
+                affected_versions => $cpansa->{affected_versions},
+                cves              => $cpansa->{cves},
+                description       => $cpansa->{description},
+                references        => $cpansa->{references},
+                reported          => $cpansa->{reported},
+                severity          => $cpansa->{severity},
+                versions          => [ map { $_->{version} } @matches ],
+                releases => [ map {"$_->{author}/$_->{name}"} @matches ],
+            };
+
+            for my $k ( keys %{$doc_data} ) {
+                delete $doc_data->{$k} unless exists $valid_keys{$k};
+            }
+
+            $bulk->update( {
+                id            => $cpansa->{cpansa_id},
+                doc           => $doc_data,
+                doc_as_upsert => true,
+            } );
+        }
+    }
+
+    $bulk->flush;
+}
+
+sub retrieve_cve_data {
+    my $self = shift;
+
+    return decode_json( path( $self->json_file )->slurp ) if $self->json_file;
+
+    my $url = $self->test ? $self->cve_dev_url : $self->cve_url;
+
+    log_info { 'Fetching data from ', $url };
+    my $resp = $self->ua->get($url);
+
+    $self->handle_error( $resp->status_line ) unless $resp->is_success;
+
+    # clean up headers if .json.gz is served as gzip type
+    # rather than json encoded with gzip
+    if ( $resp->header('Content-Type') eq 'application/x-gzip' ) {
+        $resp->header( 'Content-Type'     => 'application/json' );
+        $resp->header( 'Content-Encoding' => 'gzip' );
+    }
+
+    return decode_json( $resp->decoded_content );
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
+=pod
+
+=head1 SYNOPSIS
+
+ # bin/metacpan cve [--test] [json_file]
+
+=head1 DESCRIPTION
+
+Retrieves the CPAN CVE data from its source and
+updates our ES information.
+
+=cut
diff --git a/lib/MetaCPAN/Script/Check.pm b/lib/MetaCPAN/Script/Check.pm
new file mode 100644
index 000000000..81b115ca0
--- /dev/null
+++ b/lib/MetaCPAN/Script/Check.pm
@@ -0,0 +1,262 @@
+package MetaCPAN::Script::Check;
+
+use strict;
+use warnings;
+
+use File::Spec::Functions qw( catfile );
+use Log::Contextual       qw( :log );
+use Moose;
+use MetaCPAN::ESConfig        qw( es_doc_path );
+use MetaCPAN::Types::TypeTiny qw( Bool Int Str );
+use MetaCPAN::Util            qw( true false );
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt';
+
+has modules => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'check CPAN packages against MetaCPAN',
+);
+
+has module => (
+    is            => 'ro',
+    isa           => Str,
+    default       => '',
+    documentation => 'the name of the module you are checking',
+);
+
+has max_errors => (
+    is            => 'ro',
+    isa           => Int,
+    default       => 0,
+    documentation =>
+        'the maximum number of errors to encounter before stopping',
+);
+
+has errors_only => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'just show errors',
+);
+
+has error_count => (
+    is      => 'ro',
+    isa     => Int,
+    default => 0,
+    traits  => ['NoGetopt'],
+    writer  => '_set_error_count',
+);
+
+sub run {
+    my $self = shift;
+
+    $self->check_modules if $self->modules;
+}
+
+sub check_modules {
+    my $self = shift;
+    my ( undef, @args ) = @{ $self->extra_argv };
+    my $packages_file
+        = catfile( $self->cpan, 'modules', '02packages.details.txt' );
+    my $target = $self->module;
+    my $es     = $self->es;
+    my $packages_fh;
+
+    if ( -e $packages_file ) {
+        open( $packages_fh, '<', $packages_file )
+            or die "Could not open packages file $packages_file: $!";
+    }
+    else {
+        die q{Can't find 02packages.details.txt};
+    }
+
+    my $modules_start = 0;
+    while ( my $line = <$packages_fh> ) {
+        last if $self->max_errors && $self->error_count >= $self->max_errors;
+        chomp($line);
+        if ($modules_start) {
+            my ( $pkg, $ver, $dist ) = split( /\s+/, $line );
+            my @releases;
+
+            # we only care about packages if we aren't searching for a
+            # particular module or if it matches
+            if ( !$target || $pkg eq $target ) {
+
+             # look up this module in ElasticSearch and see what we have on it
+                my $results = $es->search(
+                    es_doc_path('file'),
+                    query => {
+                        bool => {
+                            must => [
+                                { term => { 'module.name' => $pkg } },
+                                { term => { 'authorized'  => true } },
+                                { term => { 'maturity'    => 'released' } },
+                            ],
+                        },
+                        size    => 100,    # shouldn't get more than this
+                        _source => [ qw(
+                            name
+                            release
+                            author
+                            distribution
+                            version
+                            authorized
+                            indexed
+                            maturity
+                            date
+                        ) ],
+                    },
+                );
+                my @files = @{ $results->{hits}->{hits} };
+
+                # now find the first latest releases for these files
+                foreach my $file (@files) {
+                    my $release_results = $es->search(
+                        es_doc_path('release'),
+                        query => {
+                            bool => {
+                                must => [
+                                    {
+                                        term => {
+                                            name =>
+                                                $file->{_source}->{release}
+                                        }
+                                    },
+                                    { term => { status => 'latest' } },
+                                ],
+                            },
+                            size    => 1,
+                            _source =>
+                                [qw(name status authorized version id date)],
+                        },
+                    );
+
+                    if ( $release_results->{hits}->{hits}->[0] ) {
+                        push( @releases,
+                            $release_results->{hits}->{hits}->[0] );
+                    }
+                }
+
+               # if we didn't find the latest release, then look at all of the
+               # releases so we can find out what might be wrong
+                if ( !@releases ) {
+                    foreach my $file (@files) {
+                        my $release_results = $es->search(
+                            es_doc_path('release'),
+                            query => {
+                                bool => {
+                                    must => [
+                                        {
+                                            term => {
+                                                name => $file->{_source}
+                                                    ->{release}
+                                            }
+                                        },
+                                    ],
+                                },
+                                size    => 1,
+                                _source => [
+                                    qw(name status authorized version id date)
+                                ],
+                            },
+                        );
+
+                        push( @releases,
+                            @{ $release_results->{hits}->{hits} } );
+                    }
+                }
+
+                # if we found the releases tell them about it
+                if (@releases) {
+                    if (   @releases == 1
+                        && $releases[0]->{_source}->{status} eq 'latest' )
+                    {
+                        log_info {
+                            "Found latest release $releases[0]->{_source}->{name} for $pkg";
+                        }
+                        unless $self->errors_only;
+                    }
+                    else {
+                        log_error {"Could not find latest release for $pkg"};
+                        foreach my $rel (@releases) {
+                            log_warn {
+                                "  Found release $rel->{_source}->{name}";
+                            };
+                            log_warn {
+                                "    STATUS    : $rel->{_source}->{status}";
+                            };
+                            log_warn {
+                                "    AUTORIZED : $rel->{_source}->{authorized}";
+                            };
+                            log_warn {
+                                "    DATE      : $rel->{_source}->{date}";
+                            };
+                        }
+                        $self->_set_error_count( $self->error_count + 1 );
+                    }
+                }
+                elsif (@files) {
+                    log_error {
+                        "Module $pkg doesn't have any releases in ElasticSearch!";
+                    };
+                    foreach my $file (@files) {
+                        log_warn {"  Found file $file->{_source}->{name}"};
+                        log_warn {
+                            "    RELEASE    : $file->{_source}->{release}";
+                        };
+                        log_warn {
+                            "    AUTHOR     : $file->{_source}->{author}";
+                        };
+                        log_warn {
+                            "    AUTHORIZED : $file->{_source}->{authorized}";
+                        };
+                        log_warn {"    DATE       : $file->{_source}->{date}"};
+                    }
+                    $self->_set_error_count( $self->error_count + 1 );
+                }
+                else {
+                    log_error {
+                        "Module $pkg [$dist] doesn't not appear in ElasticSearch!";
+                    };
+                    $self->_set_error_count( $self->error_count + 1 );
+                }
+                last if $self->module;
+            }
+
+        }
+        elsif ( !length $line ) {
+            $modules_start = 1;
+        }
+    }
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
+
+=pod
+
+=head1 SYNOPSIS
+
+Performs checks on the MetaCPAN data store to make sure an
+author/module/distribution has been indexed correctly and has the
+appropriate information.
+
+=head2 check_modules
+
+Checks all of the modules in CPAN against the information in ElasticSearch.
+If is C attribute exists, it will just look at packages that match
+that module name.
+
+=head1 TODO
+
+=over
+
+=item * Add support for checking authors
+
+=item * Add support for checking releases
+
+=back
+
+=cut
diff --git a/lib/MetaCPAN/Script/Checksum.pm b/lib/MetaCPAN/Script/Checksum.pm
new file mode 100644
index 000000000..57d1b68af
--- /dev/null
+++ b/lib/MetaCPAN/Script/Checksum.pm
@@ -0,0 +1,137 @@
+package MetaCPAN::Script::Checksum;
+
+use Moose;
+
+use Log::Contextual           qw( :log );
+use MetaCPAN::ESConfig        qw( es_doc_path );
+use MetaCPAN::Types::TypeTiny qw( Bool Int );
+use MetaCPAN::Util            qw( true false );
+
+use Digest::file qw( digest_file_hex );
+
+with 'MooseX::Getopt', 'MetaCPAN::Role::Script';
+
+=head1 SYNOPSIS
+
+Fill checksums for releases
+
+=cut
+
+has limit => (
+    is      => 'ro',
+    isa     => Int,
+    default => 1000,
+);
+
+has dry_run => (
+    is      => 'ro',
+    isa     => Bool,
+    default => 1,
+);
+
+sub run {
+    my $self = shift;
+
+    my $bulk;
+    if ( !$self->dry_run ) {
+        $bulk = $self->es->bulk_helper( es_doc_path('release') );
+    }
+    else {
+        log_warn {"--- DRY-RUN ---"};
+    }
+
+    log_info {"Searching for releases missing checksums"};
+
+    my $scroll = $self->es->scroll_helper(
+        es_doc_path('release'),
+        scroll => '10m',
+        body   => {
+            query => {
+                bool => {
+                    must_not => [
+                        {
+                            exists => {
+                                field => "checksum_md5"
+                            }
+                        },
+                    ],
+                },
+            },
+            _source => [qw( name download_url )],
+        },
+    );
+
+    log_warn { "Found " . $scroll->total . " releases" };
+    log_warn { "Limit is " . $self->limit };
+
+    my $count = 0;
+
+    while ( my $p = $scroll->next ) {
+        if ( $self->limit >= 0 and $count++ >= $self->limit ) {
+            log_info {"Max number of changes reached."};
+            last;
+        }
+
+        log_info { "Adding checksums for " . $p->{_source}{name} };
+
+        if ( my $download_url = $p->{_source}{download_url} ) {
+            my $file
+                = $self->cpan . "/authors" . $download_url =~ s/^.*authors//r;
+            my $checksum_md5    = digest_file_hex( $file, 'MD5' );
+            my $checksum_sha256 = digest_file_hex( $file, 'SHA-256' );
+
+            if ( $self->dry_run ) {
+                log_info { "--- MD5: " . $checksum_md5 }
+                log_info { "--- SHA256: " . $checksum_sha256 }
+            }
+            else {
+                $bulk->update( {
+                    id  => $p->{_id},
+                    doc => {
+                        checksum_md5    => $checksum_md5,
+                        checksum_sha256 => $checksum_sha256
+                    },
+                    doc_as_upsert => true,
+                } );
+            }
+        }
+        else {
+            log_info {
+                $p->{_source}{name} . " is missing a download_url"
+            };
+        }
+    }
+
+    if ( !$self->dry_run ) {
+        $bulk->flush;
+    }
+
+    log_info {'Finished adding checksums'};
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
+
+=pod
+
+=head1 SYNOPSIS
+
+ # bin/metacpan checksum --[no-]dry_run --limit X
+
+=head1 DESCRIPTION
+
+Backfill checksums for releases
+
+=head2 dry_run
+
+Don't update - just show what would have been updated (default)
+
+=head2 no-dry_run
+
+Update records
+
+=head2 limit
+
+Max number of records to update. default=1000, for unlimited set to -1
+
+=cut
diff --git a/lib/MetaCPAN/Script/Contributor.pm b/lib/MetaCPAN/Script/Contributor.pm
new file mode 100644
index 000000000..230216060
--- /dev/null
+++ b/lib/MetaCPAN/Script/Contributor.pm
@@ -0,0 +1,100 @@
+package MetaCPAN::Script::Contributor;
+
+use strict;
+use warnings;
+
+use Moose;
+
+use Log::Contextual qw( :log );
+
+use MetaCPAN::Types::TypeTiny qw( Bool HashRef Int Str );
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt',
+    'MetaCPAN::Script::Role::Contributor';
+
+has all => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'update contributors for *all* releases',
+);
+
+has distribution => (
+    is            => 'ro',
+    isa           => Str,
+    documentation =>
+        'update contributors for all releases matching distribution name',
+);
+
+has release => (
+    is            => 'ro',
+    isa           => Str,
+    documentation =>
+        'update contributors for a single release (format: author/release_name)',
+);
+
+has age => (
+    is            => 'ro',
+    isa           => Int,
+    documentation => 'update contributors for a given number of days back',
+);
+
+has author_release => (
+    is      => 'ro',
+    isa     => HashRef,
+    lazy    => 1,
+    builder => '_build_author_release',
+);
+
+sub _build_author_release {
+    my $self = shift;
+    return unless $self->release;
+    my ( $author, $release ) = split m{/}, $self->release;
+    $author && $release
+        or die
+        "Error: invalid 'release' argument (format: PAUSEID/DISTRIBUTION-VERSION)";
+    return +{
+        author  => $author,
+        release => $release,
+    };
+}
+
+sub run {
+    my $self = shift;
+
+    my $query
+        = $self->all ? { match_all => {} }
+        : $self->distribution
+        ? { term => { distribution => $self->distribution } }
+        : $self->release ? {
+        bool => {
+            must => [
+                { term => { author => $self->author_release->{author} } },
+                { term => { name   => $self->author_release->{release} } },
+            ]
+        }
+        }
+        : $self->age
+        ? { range => { date => { gte => sprintf( 'now-%dd', $self->age ) } } }
+        : return;
+
+    $self->update_contributors($query);
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ # bin/metacpan contributor --all
+ # bin/metacpan contributor --distribution Moose
+ # bin/metacpan contributor --release ETHER/Moose-2.1806
+
+=head1 DESCRIPTION
+
+Update the list of contributors (CPAN authors only) of all/matching
+releases in the 'contributor' type (index).
+
+=cut
diff --git a/lib/MetaCPAN/Script/Cover.pm b/lib/MetaCPAN/Script/Cover.pm
new file mode 100644
index 000000000..e14f51d0c
--- /dev/null
+++ b/lib/MetaCPAN/Script/Cover.pm
@@ -0,0 +1,138 @@
+package MetaCPAN::Script::Cover;
+
+use Moose;
+use namespace::autoclean;
+
+use Cpanel::JSON::XS          qw( decode_json );
+use Log::Contextual           qw( :log :dlog );
+use MetaCPAN::ESConfig        qw( es_doc_path );
+use MetaCPAN::Types::TypeTiny qw( Bool Str Uri );
+use Path::Tiny                qw( path );
+use MetaCPAN::Util            qw( hit_total true false );
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt';
+
+has cover_url => (
+    is      => 'ro',
+    isa     => Uri,
+    coerce  => 1,
+    default => 'http://cpancover.com/latest/cpancover.json',
+);
+
+has cover_dev_url => (
+    is      => 'ro',
+    isa     => Uri,
+    coerce  => 1,
+    default => 'http://cpancover.com/latest/cpancover_dev.json',
+);
+
+has test => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'Test mode (pulls smaller development data set)',
+);
+
+has json_file => (
+    is            => 'ro',
+    isa           => Str,
+    default       => 0,
+    documentation =>
+        'Path to JSON file to be read instead of URL (for testing)',
+);
+
+my %valid_keys
+    = map { $_ => 1 } qw< branch condition statement subroutine total >;
+
+sub run {
+    my $self = shift;
+    my $data = $self->retrieve_cover_data;
+    $self->index_cover_data($data);
+    return 1;
+}
+
+sub index_cover_data {
+    my ( $self, $data ) = @_;
+
+    my $bulk = $self->es->bulk_helper( es_doc_path('cover') );
+
+    log_info {'Updating the cover index'};
+
+    for my $dist ( sort keys %{$data} ) {
+        for my $version ( keys %{ $data->{$dist} } ) {
+            my $release   = $dist . '-' . $version;
+            my $rel_check = $self->es->search(
+                es_doc_path('release'),
+                size => 0,
+                body => {
+                    query => { term => { name => $release } },
+                },
+            );
+            if ( hit_total($rel_check) ) {
+                log_info { "Adding release info for '" . $release . "'" };
+            }
+            else {
+                log_warn { "Release '" . $release . "' does not exist." };
+                next;
+            }
+
+            my %doc_data = %{ $data->{$dist}{$version}{coverage}{total} };
+
+            for my $k ( keys %doc_data ) {
+                delete $doc_data{$k} unless exists $valid_keys{$k};
+            }
+
+            $bulk->update( {
+                id  => $release,
+                doc => {
+                    distribution => $dist,
+                    version      => $version,
+                    release      => $release,
+                    criteria     => \%doc_data,
+                },
+                doc_as_upsert => true,
+            } );
+        }
+    }
+
+    $bulk->flush;
+}
+
+sub retrieve_cover_data {
+    my $self = shift;
+
+    return decode_json( path( $self->json_file )->slurp ) if $self->json_file;
+
+    my $url = $self->test ? $self->cover_dev_url : $self->cover_url;
+
+    log_info { 'Fetching data from ', $url };
+    my $resp = $self->ua->get($url);
+
+    $self->handle_error( $resp->status_line ) unless $resp->is_success;
+
+    # clean up headers if .json.gz is served as gzip type
+    # rather than json encoded with gzip
+    if ( $resp->header('Content-Type') eq 'application/x-gzip' ) {
+        $resp->header( 'Content-Type'     => 'application/json' );
+        $resp->header( 'Content-Encoding' => 'gzip' );
+    }
+
+    return decode_json( $resp->decoded_content );
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
+=pod
+
+=head1 SYNOPSIS
+
+ # bin/metacpan cover [--test]
+
+=head1 DESCRIPTION
+
+Retrieves the CPAN cover data from its source and
+updates our ES information.
+
+=cut
diff --git a/lib/MetaCPAN/Script/External.pm b/lib/MetaCPAN/Script/External.pm
new file mode 100644
index 000000000..76d50ee59
--- /dev/null
+++ b/lib/MetaCPAN/Script/External.pm
@@ -0,0 +1,134 @@
+package MetaCPAN::Script::External;
+
+use Moose;
+use namespace::autoclean;
+
+use Email::Sender::Simple ();
+use Email::Simple         ();
+use Log::Contextual       qw( :log );
+use MetaCPAN::ESConfig    qw( es_doc_path );
+
+use MetaCPAN::Types::TypeTiny qw( Str );
+use MetaCPAN::Util            qw( true false );
+
+with(
+    'MetaCPAN::Role::Script',
+    'MetaCPAN::Script::Role::External::Cygwin',
+    'MetaCPAN::Script::Role::External::Debian',
+    'MooseX::Getopt',
+);
+
+has external_source => (
+    is       => 'ro',
+    isa      => Str,
+    required => 1,
+);
+
+has email_to => (
+    is       => 'ro',
+    isa      => Str,
+    required => 1,
+);
+
+sub run {
+    my $self = shift;
+    my $ret;
+
+    $ret = $self->run_cygwin if $self->external_source eq 'cygwin';
+    $ret = $self->run_debian if $self->external_source eq 'debian';
+
+    my $email_body = $ret->{errors_email_body};
+    if ($email_body) {
+        my $email = Email::Simple->create(
+            header => [
+                'Content-Type' => 'text/plain; charset=utf-8',
+                To             => $self->email_to,
+                From           => 'noreply@metacpan.org',
+                Subject        => 'Package mapping failures report for '
+                    . $self->external_source,
+                'MIME-Version' => '1.0',
+            ],
+            body => $email_body,
+        );
+        Email::Sender::Simple->send($email);
+
+        log_debug { "Sending email to " . $self->email_to . ":" };
+        log_debug {"Email body:"};
+        log_debug {$email_body};
+    }
+
+    $self->update( $ret->{dist} );
+}
+
+sub update {
+    my ( $self, $dist ) = @_;
+    my $external_source = $self->external_source;
+
+    my $scroll = $self->es->scroll_helper(
+        es_doc_path('distribution'),
+        scroll => '10m',
+        body   => {
+            query => {
+                exists => { field => "external_package." . $external_source }
+            }
+        },
+    );
+
+    my @to_remove;
+
+    while ( my $s = $scroll->next ) {
+        my $name = $s->{_source}{name};
+        next unless $name;
+
+        if ( exists $dist->{$name} ) {
+            delete $dist->{$name}
+                if $dist->{$name} eq
+                $s->{_source}{external_package}{$external_source};
+        }
+        else {
+            push @to_remove => $name;
+        }
+    }
+
+    my $bulk = $self->es->bulk_helper( es_doc_path('distribution'), );
+
+    for my $d ( keys %{$dist} ) {
+        log_debug {"[$external_source] adding $d"};
+        $bulk->update( {
+            id  => $d,
+            doc => +{
+                'external_package' => {
+                    $external_source => $dist->{$d}
+                }
+            },
+            doc_as_upsert => true,
+        } );
+    }
+
+    for my $d (@to_remove) {
+        log_debug {"[$external_source] removing $d"};
+        $bulk->update( {
+            id  => $d,
+            doc => +{
+                'external_package' => {
+                    $external_source => undef
+                }
+            }
+        } );
+    }
+
+    $bulk->flush;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
+=pod
+
+=head1 SYNOPSIS
+
+ # bin/metacpan external --external_source SOURCE
+
+=cut
+
diff --git a/lib/MetaCPAN/Script/Favorite.pm b/lib/MetaCPAN/Script/Favorite.pm
new file mode 100644
index 000000000..958203e85
--- /dev/null
+++ b/lib/MetaCPAN/Script/Favorite.pm
@@ -0,0 +1,258 @@
+package MetaCPAN::Script::Favorite;
+
+use Moose;
+
+use Log::Contextual qw( :log );
+
+use MetaCPAN::ESConfig        qw( es_doc_path );
+use MetaCPAN::Types::TypeTiny qw( Bool Int Str );
+use MetaCPAN::Util            qw( true false );
+
+with 'MooseX::Getopt', 'MetaCPAN::Role::Script';
+
+=head1 SYNOPSIS
+
+Updates the dist_fav_count field in 'file' by the count of ++ in 'favorite'
+
+=cut
+
+has queue => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'Use the queue for updates',
+);
+
+has check_missing => (
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation =>
+        'Report distributions that are missing from "file" or queue jobs if "--queue" specified',
+);
+
+has age => (
+    is            => 'ro',
+    isa           => Int,
+    documentation =>
+        'Update distributions that were voted on in the last X minutes',
+);
+
+has limit => (
+    is            => 'ro',
+    isa           => Int,
+    documentation => 'Limit number of results',
+);
+
+has distribution => (
+    is            => 'ro',
+    isa           => Str,
+    documentation => 'Update only a given distribution',
+);
+
+has count => (
+    is            => 'ro',
+    isa           => Int,
+    documentation =>
+        'Update this count to a given distribution (will only work with "--distribution"',
+);
+
+sub run {
+    my $self = shift;
+
+    if ( $self->count and !$self->distribution ) {
+        die
+            "Cannot set count in a distribution search mode, this flag only applies to a single distribution. please use together with --distribution DIST";
+    }
+
+    if ( $self->check_missing and $self->distribution ) {
+        die
+            "check_missing doesn't work in filtered mode - please remove other flags";
+    }
+
+    $self->index_favorites;
+    $self->es->indices->refresh;
+}
+
+sub index_favorites {
+    my $self = shift;
+
+    my $query = { match_all => {} };
+    my $age_filter;
+    if ( $self->age ) {
+        $age_filter = {
+            range => {
+                date => { gte => sprintf( 'now-%dm', $self->age ) }
+            }
+        };
+    }
+
+    if ( $self->distribution ) {
+        $query = { term => { distribution => $self->distribution } };
+
+    }
+    elsif ( $self->age ) {
+        my $favs = $self->es->scroll_helper(
+            es_doc_path('favorite'),
+            scroll => '5m',
+            body   => {
+                query   => $age_filter,
+                _source => [qw< distribution >],
+                size    => $self->limit || 500,
+                sort    => '_doc',
+            }
+        );
+
+        my %recent_dists;
+
+        while ( my $fav = $favs->next ) {
+            my $dist = $fav->{_source}{distribution};
+            $recent_dists{$dist}++ if $dist;
+        }
+
+        my @keys = keys %recent_dists;
+        if (@keys) {
+            $query = { terms => { distribution => \@keys } };
+        }
+    }
+
+    # get total fav counts for distributions
+
+    my %dist_fav_count;
+
+    if ( $self->count ) {
+        $dist_fav_count{ $self->distribution } = $self->count;
+    }
+    else {
+        my $favs = $self->es->scroll_helper(
+            es_doc_path('favorite'),
+            scroll => '30s',
+            body   => {
+                query   => $query,
+                _source => [qw< distribution >],
+                size    => 500,
+                sort    => '_doc',
+            },
+        );
+
+        while ( my $fav = $favs->next ) {
+            my $dist = $fav->{_source}{distribution};
+            $dist_fav_count{$dist}++ if $dist;
+        }
+
+        log_debug {"Done counting favs for distributions"};
+    }
+
+    # Report missing distributions if requested
+
+    if ( $self->check_missing ) {
+        my %missing;
+        my @age_filter;
+        if ( $self->age ) {
+            @age_filter = ( must => [$age_filter] );
+        }
+
+        my $files = $self->es->scroll_helper(
+            es_doc_path('file'),
+            scroll => '15m',
+            body   => {
+                query => {
+                    bool => {
+                        must_not => [
+                            { range => { dist_fav_count => { gte => 1 } } }
+                        ],
+                        @age_filter,
+                    }
+                },
+                _source => [qw< distribution >],
+                size    => 500,
+                sort    => '_doc',
+            },
+        );
+
+        while ( my $file = $files->next ) {
+            my $dist = $file->{_source}{distribution};
+            next unless $dist;
+            next if exists $missing{$dist} or exists $dist_fav_count{$dist};
+
+            if ( $self->queue ) {
+                log_debug {"Queueing: $dist"};
+
+                my @count_flag;
+                if ( $self->count or $dist_fav_count{$dist} ) {
+                    @count_flag = (
+                        '--count', $self->count || $dist_fav_count{$dist}
+                    );
+                }
+
+                $self->_add_to_queue( index_favorite =>
+                        [ '--distribution', $dist, @count_flag ] =>
+                        { priority => 0, attempts => 10 } );
+            }
+            else {
+                log_debug {"Found missing: $dist"};
+            }
+
+            $missing{$dist} = 1;
+            last if $self->limit and scalar( keys %missing ) >= $self->limit;
+        }
+
+        my $total_missing = scalar( keys %missing );
+        log_debug {"Total missing: $total_missing"} unless $self->queue;
+
+        return;
+    }
+
+    # Update fav counts for files per distributions
+
+    for my $dist ( keys %dist_fav_count ) {
+        log_debug {"Dist $dist"};
+
+        if ( $self->queue ) {
+            $self->_add_to_queue(
+                index_favorite => [
+                    '--distribution',
+                    $dist,
+                    '--count',
+                    ( $self->count ? $self->count : $dist_fav_count{$dist} )
+                ] => { priority => 0, attempts => 10 }
+            );
+
+        }
+        else {
+            my $bulk = $self->es->bulk_helper(
+                es_doc_path('file'),
+                max_count => 250,
+                timeout   => '120m',
+            );
+
+            my $files = $self->es->scroll_helper(
+                es_doc_path('file'),
+                scroll => '15s',
+                body   => {
+                    query   => { term => { distribution => $dist } },
+                    _source => false,
+                    size    => 500,
+                    sort    => '_doc',
+                },
+            );
+
+            while ( my $file = $files->next ) {
+                my $id  = $file->{_id};
+                my $cnt = $dist_fav_count{$dist};
+
+                log_debug {"Updating file id $id with fav_count $cnt"};
+
+                $bulk->update( {
+                    id  => $file->{_id},
+                    doc => { dist_fav_count => $cnt },
+                } );
+            }
+
+            $bulk->flush;
+        }
+    }
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
diff --git a/lib/MetaCPAN/Script/First.pm b/lib/MetaCPAN/Script/First.pm
new file mode 100644
index 000000000..71b2c3a79
--- /dev/null
+++ b/lib/MetaCPAN/Script/First.pm
@@ -0,0 +1,75 @@
+package MetaCPAN::Script::First;
+
+use strict;
+use warnings;
+
+use Log::Contextual qw( :log );
+use Moose;
+use MetaCPAN::Types::TypeTiny qw( Str );
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt';
+
+has distribution => (
+    is            => 'ro',
+    isa           => Str,
+    documentation => q{set the 'first' for only this distribution},
+);
+
+sub run {
+    my $self          = shift;
+    my $distributions = $self->model->doc("distribution");
+    $distributions
+        = $distributions->query( { term => { name => $self->distribution } } )
+        if $self->distribution;
+    $distributions = $distributions->size(500)->scroll;
+
+    log_info { "processing " . $distributions->total . " distributions" };
+
+    while ( my $distribution = $distributions->next ) {
+        my $release = $distribution->set_first_release;
+        $release
+            ? log_debug {
+            "@{[ $release->name ]} by @{[ $release->author ]} was first";
+            }
+            : log_warn {
+            "no release found for distribution @{[$distribution->name]}";
+            };
+    }
+
+    # Everything changed - reboot the world!
+    $self->cdn_purge_all;
+
+    1;
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
+
+=pod
+
+=head1 NAME
+
+MetaCPAN::Script::First - Set the C bit after a full reindex
+
+=head1 SYNOPSIS
+
+ $ bin/metacpan first --level debug
+ $ bin/metacpan first --distribution Moose
+
+=head1 DESCRIPTION
+
+Setting the L bit cannot be
+set when indexing archives in parallel, e.g. when doing a full reindex.
+This script sets the C bit once all archives have been indexed.
+
+See L for more
+information.
+
+=head1 OPTIONS
+
+=head2 distribution
+
+Only set the L property for releases of this distribution.
+
+=cut
+
diff --git a/lib/MetaCPAN/Script/Latest.pm b/lib/MetaCPAN/Script/Latest.pm
new file mode 100644
index 000000000..e678806fd
--- /dev/null
+++ b/lib/MetaCPAN/Script/Latest.pm
@@ -0,0 +1,343 @@
+package MetaCPAN::Script::Latest;
+
+use strict;
+use warnings;
+
+use Log::Contextual qw( :log );
+use Moose;
+use CPAN::DistnameInfo          ();
+use DateTime::Format::ISO8601   ();
+use MetaCPAN::ESConfig          qw( es_doc_path );
+use MetaCPAN::Types::TypeTiny   qw( Bool Str );
+use MetaCPAN::Util              qw( true false );
+use Parse::CPAN::Packages::Fast ();
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt';
+
+has dry_run => (
+    is      => 'ro',
+    isa     => Bool,
+    default => 0,
+);
+
+has distribution => (
+    is  => 'ro',
+    isa => Str,
+);
+
+has packages => (
+    is      => 'ro',
+    lazy    => 1,
+    builder => '_build_packages',
+    traits  => ['NoGetopt'],
+);
+
+has force => (
+    is      => 'ro',
+    isa     => Bool,
+    default => 0,
+);
+
+sub _build_packages {
+    return Parse::CPAN::Packages::Fast->new(
+        shift->cpan->child(qw(modules 02packages.details.txt.gz))
+            ->stringify );
+}
+
+sub _queue_latest {
+    my $self = shift;
+    my $dist = shift || $self->distribution;
+
+    log_info { "queueing " . $dist };
+    $self->_add_to_queue(
+        index_latest =>
+            [ ( $self->force ? '--force' : () ), '--distribution', $dist ],
+        { attempts => 3 }
+    );
+}
+
+sub run {
+    my $self = shift;
+
+    if ( $self->dry_run ) {
+        log_info {'Dry run: updates will not be written to ES'};
+    }
+
+    my $p = $self->packages;
+    $self->es->indices->refresh;
+
+    # If a distribution name is passed get all the package names
+    # from 02packages that match that distribution so we can limit
+    # the ES query to just those modules.
+    my @filter;
+    if ( my $distribution = $self->distribution ) {
+        foreach my $package ( $p->packages ) {
+            my $dist = $p->package($package)->distribution->dist;
+            push( @filter, $package )
+                if ( $dist && $dist eq $distribution );
+        }
+        log_info { "$distribution consists of " . @filter . ' modules' };
+    }
+
+    return if ( !@filter && $self->distribution );
+
+    # if we are just queueing a single distribution
+    if ( $self->queue and $self->distribution ) {
+        $self->_queue_latest();
+        return;
+    }
+
+    my %upgrade;
+    my %downgrade;
+    my %queued_distributions;
+
+    my $total       = @filter;
+    my $found_total = 0;
+
+    my @module_filters;
+    if (@filter) {
+        while (@filter) {
+            my @modules = splice @filter, 0, 500;
+
+            push @module_filters,
+                [
+                { term  => { 'module.indexed' => true } },
+                { terms => { "module.name"    => \@modules } },
+                ];
+        }
+    }
+    else {
+        push @module_filters,
+            [
+            { term   => { 'module.indexed' => true } },
+            { exists => { field            => "module.name" } },
+            ];
+    }
+    for my $filter (@module_filters) {
+
+        # This query will be used to produce a (scrolled) list of
+        # 'file' type records where the module.name matches the
+        # distribution name and which are released &
+        # indexed (the 'leading' module)
+        my $query = {
+            bool => {
+                must => [
+                    {
+                        nested => {
+                            path  => 'module',
+                            query => { bool => { must => $filter } }
+                        }
+                    },
+                    { term => { 'maturity' => 'released' } },
+                ],
+                must_not => [
+                    { term => { status       => 'backpan' } },
+                    { term => { distribution => 'perl' } }
+                ]
+            }
+        };
+
+        log_debug {
+            'Searching for ' . @$filter . ' of ' . $total . ' modules'
+        }
+        if @module_filters > 1;
+
+        my $scroll = $self->es->scroll_helper( {
+            es_doc_path('file'),
+            size => 100,
+            body => {
+                query   => $query,
+                _source => [
+                    qw(author date distribution download_url module.name release status)
+                ],
+                sort => '_doc',
+            },
+        } );
+
+        $found_total += $scroll->total;
+
+        log_debug { 'Found ' . $scroll->total . ' modules' };
+        log_debug { 'Found ' . $found_total . 'total modules' }
+        if @$filter != $total and $filter == $module_filters[-1];
+
+        my $i = 0;
+
+        # For each file...
+        while ( my $file = $scroll->next ) {
+            $i++;
+            log_debug { "$i of " . $scroll->total } unless ( $i % 100 );
+            my $file_data = $file->{_source};
+
+       # Convert module name into Parse::CPAN::Packages::Fast::Package object.
+            my @modules = grep {defined}
+                map {
+                eval { $p->package( $_->{name} ) }
+                } @{ $file_data->{module} };
+
+            $file_data->{date}
+                = DateTime::Format::ISO8601->parse_datetime(
+                $file_data->{date} );
+
+            # For each of the packages in this file...
+            foreach my $module (@modules) {
+
+           # Get P:C:P:F:Distribution (CPAN::DistnameInfo) object for package.
+                my $dist = $module->distribution;
+
+                if ( $self->queue ) {
+                    my $d = $dist->dist;
+                    $self->_queue_latest($d)
+                        unless exists $queued_distributions{$d};
+                    $queued_distributions{$d} = 1;
+                    next;
+                }
+
+               # If 02packages has the same author/release for this package...
+
+                # NOTE: CPAN::DistnameInfo doesn't parse some weird uploads
+                # (like /\.pm\.gz$/) so distvname might not be present.
+                # I assume cpanid always will be.
+                if (   defined( $dist->distvname )
+                    && $dist->distvname eq $file_data->{release}
+                    && $dist->cpanid eq $file_data->{author} )
+                {
+                    my $upgrade = $upgrade{ $file_data->{distribution} };
+
+                    # If multiple versions of a dist appear in 02packages
+                    # only mark the most recent upload as latest.
+                    next
+                        if $upgrade && $upgrade->{date} > $file_data->{date};
+                    $upgrade{ $file_data->{distribution} } = $file_data;
+                }
+                elsif ( $file_data->{status} eq 'latest' ) {
+                    $downgrade{ $file_data->{release} } = $file_data;
+                }
+            }
+        }
+    }
+
+    my $bulk = $self->es->bulk_helper( es_doc_path('file') );
+
+    my %to_purge;
+
+    while ( my ( $dist, $file_data ) = each %upgrade ) {
+
+        # Don't reindex if already marked as latest.
+        # This just means that it hasn't changed (query includes 'latest').
+        next if ( !$self->force and $file_data->{status} eq 'latest' );
+
+        $to_purge{ $file_data->{download_url} } = 1;
+
+        $self->reindex( $bulk, $file_data, 'latest' );
+    }
+
+    while ( my ( $release, $file_data ) = each %downgrade ) {
+
+        # Don't downgrade if this release version is also marked as latest.
+        # This could happen if a module is moved to a new dist
+        # but the old dist remains (with other packages).
+        # This could also include bug fixes in our indexer, PAUSE, etc.
+        next
+            if ( !$self->force
+            && $upgrade{ $file_data->{distribution} }
+            && $upgrade{ $file_data->{distribution} }->{release} eq
+            $file_data->{release} );
+
+        $to_purge{ $file_data->{download_url} } = 1;
+
+        $self->reindex( $bulk, $file_data, 'cpan' );
+    }
+    $bulk->flush;
+    $self->es->indices->refresh;
+
+    # Call Fastly to purge
+    $self->purge_cpan_distnameinfos( [
+        map CPAN::DistnameInfo->new($_), keys %to_purge ] );
+}
+
+# Update the status for the release and all the files.
+sub reindex {
+    my ( $self, $bulk, $source, $status ) = @_;
+
+    # Update the status on the release.
+    my $releases = $self->es->search( {
+        es_doc_path('release'),
+        body => {
+            query => {
+                bool => {
+                    must => [
+                        { term => { author => $source->{author} } },
+                        { term => { name   => $source->{release} } },
+                    ],
+                },
+            },
+        },
+        _source => false,
+    } );
+    my $release = $releases->{hits}{hits}[0]{_id};
+
+    log_info {
+        $status eq 'latest' ? 'Upgrading ' : 'Downgrading ',
+            'release ', $source->{release}, "($release)";
+    };
+
+    if ( !$self->dry_run ) {
+        $self->es->update( {
+            es_doc_path('release'),
+            id   => $release,
+            body => {
+                doc => {
+                    status => $status,
+                },
+            },
+        } );
+    }
+
+    # Get all the files for the release.
+    my $scroll = $self->es->scroll_helper(
+        es_doc_path('file'),
+        size => 100,
+        body => {
+            query => {
+                bool => {
+                    must => [
+                        { term => { 'release' => $source->{release} } },
+                        { term => { 'author'  => $source->{author} } },
+                    ],
+                },
+            },
+            _source => [ 'status', 'file' ],
+            sort    => '_doc',
+        },
+    );
+
+    while ( my $row = $scroll->next ) {
+        my $source = $row->{_source};
+        log_trace {
+            $status eq 'latest' ? 'Upgrading ' : 'Downgrading ',
+                'file ', $source->{name} || q[];
+        };
+
+        # Use bulk update to overwrite the status for X files at a time.
+        $bulk->update( { id => $row->{_id}, doc => { status => $status } } )
+            unless $self->dry_run;
+    }
+
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
+
+__END__
+
+=head1 SYNOPSIS
+
+ # bin/metacpan latest
+
+ # bin/metacpan latest --dry_run
+
+=head1 DESCRIPTION
+
+After importing releases from cpan, this script will set the status
+to latest on the most recent release, its files and dependencies.
+It also makes sure that there is only one latest release per distribution.
diff --git a/lib/MetaCPAN/Script/Mapping.pm b/lib/MetaCPAN/Script/Mapping.pm
new file mode 100644
index 000000000..128c78b76
--- /dev/null
+++ b/lib/MetaCPAN/Script/Mapping.pm
@@ -0,0 +1,764 @@
+package MetaCPAN::Script::Mapping;
+
+use Moose;
+
+use Cpanel::JSON::XS          ();
+use Log::Contextual           qw( :log );
+use MetaCPAN::ESConfig        qw( es_config );
+use MetaCPAN::Types::TypeTiny qw( Bool HashRef Int );
+use Time::HiRes               qw( sleep time );
+
+use constant {
+    EXPECTED     => 1,
+    NOT_EXPECTED => 0,
+};
+
+with 'MetaCPAN::Role::Script', 'MooseX::Getopt';
+
+has arg_deploy_mapping => (
+    init_arg      => 'delete',
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'delete index if it exists already',
+);
+
+has arg_delete_all => (
+    init_arg      => 'all',
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation =>
+        'delete ALL existing indices (only effective in combination with "--delete")',
+);
+
+has arg_verify_mapping => (
+    init_arg      => 'verify',
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'verify deployed index structure against definition',
+);
+
+has arg_cluster_info => (
+    init_arg      => 'show_cluster_info',
+    is            => 'ro',
+    isa           => Bool,
+    default       => 0,
+    documentation => 'show basic info about cluster and indices',
+);
+
+has arg_await_timeout => (
+    init_arg      => 'await',
+    is            => 'ro',
+    isa           => Int,
+    default       => 15,
+    documentation =>
+        'seconds before connection is considered failed with timeout',
+);
+
+has cluster_info => (
+    isa     => HashRef,
+    traits  => ['Hash'],
+    is      => 'rw',
+    lazy    => 1,
+    default => sub { {} },
+);
+
+has indices_info => (
+    isa     => HashRef,
+    traits  => ['Hash'],
+    is      => 'rw',
+    lazy    => 1,
+    default => sub { {} },
+);
+
+sub run {
+    my $self = shift;
+
+    # Wait for the ElasticSearch Engine to become ready
+    if ( $self->await ) {
+        if ( $self->arg_deploy_mapping ) {
+            if ( $self->arg_delete_all ) {
+                $self->check_health;
+                $self->delete_all;
+            }
+            unless ( $self->deploy_mapping ) {
+                $self->print_error("Indices Re-creation has failed!");
+                $self->exit_code(1);
+            }
+        }
+
+        if ( $self->arg_verify_mapping ) {
+            $self->check_health;
+            unless ( $self->indices_valid( $self->_build_index_config ) ) {
+                $self->print_error("Indices Verification has failed!");
+                $self->exit_code(1);
+            }
+        }
+
+        if ( $self->arg_cluster_info ) {
+            $self->check_health;
+            $self->show_info;
+        }
+    }
+
+# The run() method is expected to communicate Success to the superior execution level
+    return ( $self->exit_code == 0 ? 1 : 0 );
+}
+
+sub delete_all {
+    my $self                = $_[0];
+    my $runtime_environment = 'production';
+
+    $runtime_environment = $ENV{'PLACK_ENV'}
+        if ( defined $ENV{'PLACK_ENV'} );
+    $runtime_environment = $ENV{'MOJO_MODE'}
+        if ( defined $ENV{'MOJO_MODE'} );
+
+    my $is_development
+        = $ENV{HARNESS_ACTIVE}
+        || $runtime_environment eq 'development'
+        || $runtime_environment eq 'testing';
+
+    if ($is_development) {
+        foreach my $name ( grep !/\A\./, keys %{ $self->indices_info } ) {
+            $self->_delete_index($name);
+        }
+    }
+    else {
+        #Set System Error: 1 - EPERM - Operation not permitted
+        $self->exit_code(1);
+        $self->print_error("Operation not permitted!");
+        $self->handle_error(
+            "Operation not permitted in environment: $runtime_environment",
+            1 );
+    }
+}
+
+sub _delete_index {
+    my ( $self, $name ) = @_;
+
+    log_info {"Deleting index: $name"};
+    my $idx = $self->es->indices;
+    $idx->delete( index => $name );
+
+    my $exists;
+    my $end = time + 2;
+    while ( time < $end ) {
+        $exists = $idx->exists( index => $name ) or last;
+        sleep 0.1;
+    }
+    if ($exists) {
+        log_error {"Failed to delete index: $name"};
+    }
+    return $exists;
+}
+
+sub show_info {
+    my $self    = $_[0];
+    my $info_rs = {
+        'cluster_info' => \%{ $self->cluster_info },
+        'indices_info' => \%{ $self->indices_info },
+    };
+    log_info { Cpanel::JSON::XS->new->utf8->pretty->encode($info_rs) };
+}
+
+sub _build_index_config {
+    my $self        = $_[0];
+    my $docs        = es_config->documents;
+    my $indices     = {};
+    my $api_version = $self->es->api_version;
+    for my $name ( sort keys %$docs ) {
+        my $doc   = $docs->{$name};
+        my $index = $doc->{index}
+            or die "no index defined for $name documents";
+        die "$index specified for multiple documents"
+            if $indices->{$index};
+        my $mapping  = es_config->mapping( $name, $api_version );
+        my $settings = es_config->index_settings( $name, $api_version );
+        if ( $api_version le '6_0' ) {
+            my $type = $doc->{type}
+                or die "no type defined for $name documents";
+            $mapping = { $type => $mapping };
+        }
+        $indices->{$index} = {
+            settings => $settings,
+            mappings => $mapping,
+        };
+    }
+
+    return $indices;
+}
+
+sub deploy_mapping {
+    my $self = shift;
+
+    $self->are_you_sure(
+        'this will delete EVERYTHING and re-create the (empty) indexes');
+
+    # Deserialize the Index Mapping Structure
+    my $rindices = $self->_build_index_config;
+
+    my $es = $self->es;
+
+    # recreate the indices and apply the mapping
+
+    for my $idx ( sort keys %$rindices ) {
+        $self->_delete_index($idx)
+            if $es->indices->exists( index => $idx );
+
+        log_info {"Creating index: $idx"};
+
+        $es->indices->create( index => $idx, body => $rindices->{$idx} );
+    }
+
+    $self->check_health(1);
+
+    # done
+    log_info {"Done."};
+
+    return $self->indices_valid($rindices);
+}
+
+sub _compare_mapping {
+    my ( $self, $sname, $rdeploy, $rmodel ) = @_;
+    my $imatch = 0;
+
+    if ( defined $rdeploy && defined $rmodel ) {
+        my $json_parser = Cpanel::JSON::XS->new->allow_nonref;
+        my ( $deploy_type, $deploy_value );
+        my ( $model_type,  $model_value );
+
+        $imatch = 1;
+
+        if ( ref $rdeploy eq 'HASH' ) {
+            foreach my $sfield ( sort keys %$rdeploy ) {
+                if (   defined $rdeploy->{$sfield}
+                    && defined $rmodel->{$sfield} )
+                {
+                    $deploy_type  = ref( $rdeploy->{$sfield} );
+                    $model_type   = ref( $rmodel->{$sfield} );
+                    $deploy_value = $rdeploy->{$sfield};
+                    $model_value  = $rmodel->{$sfield};
+
+                    if ( $deploy_type eq 'JSON::PP::Boolean' ) {
+                        $deploy_type = '';
+                        $deploy_value
+                            = $json_parser->encode( $rdeploy->{$sfield} );
+                    }
+
+                    if ( $model_type eq 'JSON::PP::Boolean' ) {
+                        $model_type = '';
+                        $model_value
+                            = $json_parser->encode( $rmodel->{$sfield} );
+                    }
+
+                    if ( $deploy_type ne '' ) {
+                        if (   $deploy_type eq 'HASH'
+                            || $deploy_type eq 'ARRAY' )
+                        {
+                            $imatch = (
+                                $imatch && $self->_compare_mapping(
+                                    $sname . '.' . $sfield, $deploy_value,
+                                    $model_value
+                                )
+                            );
+                        }
+                        else {    # No Hash nor Array
+                            if ( ${$deploy_value} ne ${$model_value} ) {
+                                log_error {
+                                    'Mismatch field: '
+                                        . $sname . '.'
+                                        . $sfield . ' ('
+                                        . ${$deploy_value} . ' <> '
+                                        . ${$model_value} . ')'
+                                };
+                                $imatch = 0;
+                            }
+                        }
+                    }
+                    else {    # Scalar Value
+                        if (
+                               $sfield eq 'type'
+                            && $model_value eq 'string'
+                            && (   $deploy_value eq 'text'
+                                || $deploy_value eq 'keyword' )
+                            )
+                        {
+                            # ES5 automatically converts string types to text
+                            # or keyword. once we upgrade to ES5 and update
+                            # our mappings, this special case can be removed.
+                        }
+                        elsif ($sfield eq 'index'
+                            && $model_value eq 'no'
+                            && $deploy_value eq 'false' )
+                        {
+                            # another ES5 string automatic conversion
+                        }
+                        elsif ( $deploy_value ne $model_value ) {
+                            log_error {
+                                'Mismatch field: '
+                                    . $sname . '.'
+                                    . $sfield . ' ('
+                                    . $deploy_value . ' <> '
+                                    . $model_value . ')'
+                            };
+                            $imatch = 0;
+                        }
+                    }
+                }
+                else {
+                    unless ( defined $rdeploy->{$sfield} ) {
+                        log_error {
+                            'Missing field: ' . $sname . '.' . $sfield
+                        };
+                        $imatch = 0;
+
+                    }
+
+                    unless ( defined $rmodel->{$sfield} ) {
+                        if (   $sfield eq 'payloads'
+                            && $rmodel->{type}
+                            && $rmodel->{type} eq 'completion'
+                            && !$rdeploy->{$sfield} )
+                        {
+                            # ES5 doesn't allow payloads option. we've removed
+                            # it from our mapping. but it gets a default
+                            # value. ignore the default.
+                        }
+                        else {
+                            log_error {
+                                'Missing definition: ' . $sname . '.'
+                                    . $sfield
+                            };
+                            $imatch = 0;
+                        }
+                    }
+                }
+            }
+        }
+        elsif ( ref $rdeploy eq 'ARRAY' ) {
+            foreach my $iindex (@$rdeploy) {
+                if (   defined $rdeploy->[$iindex]
+                    && defined $rmodel->[$iindex] )
+                {
+                    $deploy_type  = ref( $rdeploy->[$iindex] );
+                    $model_type   = ref( $rmodel->[$iindex] );
+                    $deploy_value = $rdeploy->[$iindex];
+                    $model_value  = $rmodel->[$iindex];
+
+                    if ( $deploy_type eq 'JSON::PP::Boolean' ) {
+                        $deploy_type = '';
+                        $deploy_value
+                            = $json_parser->encode( $rdeploy->[$iindex] );
+                    }
+
+                    if ( $model_type eq 'JSON::PP::Boolean' ) {
+                        $model_type = '';
+                        $model_value
+                            = $json_parser->encode( $rmodel->[$iindex] );
+                    }
+
+                    if ( $deploy_type eq '' ) {    # Reference Value
+                        if (   $deploy_type eq 'HASH'
+                            || $deploy_type eq 'ARRAY' )
+                        {
+                            $imatch = (
+                                $imatch && $self->_compare_mapping(
+                                    $sname . '[' . $iindex . ']',
+                                    $deploy_value,
+                                    $model_value
+                                )
+                            );
+                        }
+                        else {    # No Hash nor Array
+                            if ( ${$deploy_value} ne ${$model_value} ) {
+                                log_error {
+                                    'Mismatch field: '
+                                        . $sname . '['
+                                        . $iindex . '] ('
+                                        . ${$deploy_value} . ' <> '
+                                        . ${$model_value} . ')'
+                                };
+                                $imatch = 0;
+                            }
+                        }
+                    }
+                    else {    # Scalar Value
+                        if ( $deploy_value ne $model_value ) {
+                            log_error {
+                                'Mismatch field: '
+                                    . $sname . '['
+                                    . $iindex . '] ('
+                                    . $deploy_value . ' <> '
+                                    . $model_value . ')'
+                            };
+                            $imatch = 0;
+                        }
+                    }
+                }
+                else {    # Missing Field
+                    unless ( defined $rdeploy->[$iindex] ) {
+                        log_error {
+                            'Missing field: ' . $sname . '[' . $iindex . ']'
+                        };
+                        $imatch = 0;
+
+                    }
+                    unless ( defined $rmodel->[$iindex] ) {
+                        log_error {
+                            'Missing definition: ' . $sname . '[' . $iindex
+                                . ']'
+                        };
+                        $imatch = 0;
+                    }
+                }
+            }
+        }
+    }
+    else {    # Missing Field
+        unless ( defined $rdeploy ) {
+            log_error { 'Missing field: ' . $sname };
+            $imatch = 0;
+        }
+        unless ( defined $rmodel ) {
+            log_error { 'Missing definition: ' . $sname };
+            $imatch = 0;
+        }
+    }
+
+    if ( $self->{'logger'}->is_debug ) {
+        if ($imatch) {
+            log_debug {"field '$sname': ok"};
+        }
+        else {
+            log_debug {"field '$sname': failed!"};
+        }
+    }
+
+    return $imatch;
+}
+
+sub indices_valid {
+    my ( $self, $config_indices ) = @_;
+    my $valid = 0;
+
+    if ( defined $config_indices && ref $config_indices eq 'HASH' ) {
+        my $deploy_indices = $self->es->indices->get_mapping;
+        $valid = 1;
+
+        for my $idx ( sort keys %$config_indices ) {
+            my $config_mappings = $config_indices->{$idx}
+                && $config_indices->{$idx}->{'mappings'};
+            my $deploy_mappings = $deploy_indices->{$idx}
+                && $deploy_indices->{$idx}->{'mappings'};
+            if ( !$deploy_mappings ) {
+                log_error {"Missing index: $idx"};
+                $valid = 0;
+                next;
+            }
+
+            log_info {
+                "Verifying index: $idx"
+            };
+
+            if ( $self->_compare_mapping(
+                $idx, $deploy_mappings, $config_mappings
+            ) )
+            {
+                log_info {
+                    "Correct index: $idx (mapping deployed)"
+                };
+            }
+            else {
+                log_error {
+                    "Broken index: $idx (mapping does not match definition)"
+                };
+                $valid = 0;
+            }
+        }
+    }
+
+    if ($valid) {
+        log_info {"Verification indices: ok"};
+    }
+    else {
+        log_info {"Verification indices: failed"};
+    }
+
+    return $valid;
+}
+
+sub _get_indices_info {
+    my ( $self, $irefresh ) = @_;
+
+    if ( $irefresh || scalar( keys %{ $self->indices_info } ) == 0 ) {
+        my $sinfo_rs = $self->es->cat->indices( h => [ 'index', 'health' ] );
+        my $sindices_parsing = qr/^([^[:space:]]+) +([^[:space:]]+)/m;
+
+        $self->indices_info( {} );
+
+        while ( $sinfo_rs =~ /$sindices_parsing/g ) {
+            $self->indices_info->{$1}
+                = { 'index_name' => $1, 'health' => $2 };
+        }
+    }
+}
+
+sub check_health {
+    my ( $self, $irefresh ) = @_;
+    my $ihealth = 0;
+
+    $irefresh = 0 unless ( defined $irefresh );
+
+    $ihealth = $self->await;
+
+    if ($ihealth) {
+        $self->_get_indices_info($irefresh);
+
+        foreach ( keys %{ $self->indices_info } ) {
+            $ihealth = 0
+                if ( $self->indices_info->{$_}->{'health'} eq 'red' );
+        }
+    }
+
+    return $ihealth;
+}
+
+sub await {
+    my $self   = $_[0];
+    my $iready = 0;
+
+    if ( scalar( keys %{ $self->cluster_info } ) == 0 ) {
+        my $es       = $self->es;
+        my $iseconds = 0;
+
+        log_info {"Awaiting Elasticsearch ..."};
+
+        do {
+            eval {
+                $iready = $es->ping;
+
+                if ($iready) {
+                    log_info {
+                        "Awaiting $iseconds / "
+                            . $self->arg_await_timeout
+                            . " : ready"
+                    };
+
+                    $self->cluster_info( \%{ $es->info } );
+                }
+            };
+
+            if ($@) {
+                if ( $iseconds < $self->arg_await_timeout ) {
+                    log_info {
+                        "Awaiting $iseconds / "
+                            . $self->arg_await_timeout
+                            . " : unavailable - sleeping ..."
+                    };
+
+                    sleep(1);
+
+                    $iseconds++;
+                }
+                else {
+                    log_error {
+                        "Awaiting $iseconds / "
+                            . $self->arg_await_timeout
+                            . " : unavailable - timeout!"
+                    };
+
+                    #Set System Error: 112 - EHOSTDOWN - Host is down
+                    $self->exit_code(112);
+                    $self->handle_error( $@, 1 );
+                }
+            }
+        } while ( !$iready && $iseconds <= $self->arg_await_timeout );
+    }
+    else {
+        #ElasticSearch Service is available
+        $iready = 1;
+    }
+
+    return $iready;
+}
+
+__PACKAGE__->meta->make_immutable;
+1;
+
+__END__
+
+=pod
+
+=head1 NAME
+
+MetaCPAN::Script::Mapping - Script to set the index and mapping the types
+
+=head1 SYNOPSIS
+
+ # bin/metacpan mapping --show_cluster_info   # show basic info about the cluster and indices
+ # bin/metacpan mapping --delete
+ # bin/metacpan mapping --delete --all        # deletes ALL indices in the cluster
+ # bin/metacpan mapping --verify              # compare deployed indices with project definitions
+
+=head1 DESCRIPTION
+
+This is the index mapping handling script.
+Used rarely, but carries the most important task of setting
+the index and mapping the types.
+
+=head1 OPTIONS
+
+This Script accepts the following options
+
+=over 4
+
+=item Option C<--show_cluster_info>
+
+This option makes the Script show basic information about the I Cluster
+and its indices.
+This information has to be collected with the C Method.
+On Script start-up it is empty.
+
+    bin/metacpan mapping --show_cluster_info
+
+See L>
+
+=item Option C<--delete>
+
+This option makes the Script delete all indices configured in the project and re-create them emtpy.
+It verifies the index integrity of the indices calling the methods
+C and C.
+If the C Method fails it will report an error.
+
+    bin/metacpan mapping --delete
+
+B If the mapping deployment fails it exits the Script with B C< 1 >.
+
+See L>
+
+See L>
+
+See L>
+
+=item Option C<--all>
+
+This option is only effective in combination with Option C<--delete>.
+It uses the information gathered by C to delete
+B indices in the I Cluster.
+This option is usefull to reconstruct a broken I Cluster
+
+    bin/metacpan mapping --delete --all
+
+B It will throw an exceptions when not performed in an development or
+testing environment.
+
+See L
  • $_
  • " } + sort map /^Login::(.*)/, $c->controllers; + $c->res->content_type('text/html'); + $c->res->body(qq{

    Login via

      @login
    }); +} + +sub update_user { + my ( $self, $c, $type, $id, $data ) = @_; + my $model = $c->model('ESModel')->doc('account'); + my $user = $model->find( { name => $type, key => $id } ); + unless ($user) { + $user = $model->get( $c->user->id ) + if ( $c->session->{__user} ); + $user ||= $model->new_document; + $user->add_identity( { name => $type, key => $id, extra => $data } ); + $user->put( { refresh => true } ); + } + $c->authenticate( { user => $user } ); + + # Find the cookie we set earlier. + if ( my $cid = $c->req->cookie('oauth_tmp') ) { + + # Expire the cookie (tell the browser to remove it). + $cid->expires('-1y'); + + # Pass the params to the oauth controller so it can use them + # to redirect the user to the appropriate place. + # NOTE: This controller is `lib/Catalyst/Plugin/OAuth2/Provider.pm`. + $c->res->redirect( + $c->uri_for( '/oauth2/authorize', decode_json( $cid->value ) ) ); + $c->res->cookies->{oauth_tmp} = $cid; + } + + # Without the cookie we don't know where to send them. + else { + $c->res->redirect('/user'); + } + $c->detach; + +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Login/GitHub.pm b/lib/MetaCPAN/Server/Controller/Login/GitHub.pm new file mode 100644 index 000000000..e8f9de160 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Login/GitHub.pm @@ -0,0 +1,49 @@ +package MetaCPAN::Server::Controller::Login::GitHub; + +use Moose; + +use Cpanel::JSON::XS qw( decode_json ); +use HTTP::Request::Common qw( GET POST ); +use LWP::UserAgent (); + +BEGIN { extends 'MetaCPAN::Server::Controller::Login' } + +has [qw(consumer_key consumer_secret)] => ( + is => 'ro', + required => 1, +); + +sub index : Path Args(0) { + my ( $self, $c ) = @_; + if ( my $code = $c->req->params->{code} ) { + my $ua = LWP::UserAgent->new; + my $res = $ua->request( + POST 'https://github.com/login/oauth/access_token', + [ + client_id => $self->consumer_key, + redirect_uri => $c->uri_for( $self->action_for('index') ), + client_secret => $self->consumer_secret, + code => $code, + ] + ); + $c->controller('OAuth2')->redirect( $c, error => $1 ) + if ( $res->content =~ /^error=(.*)$/ ); + ( my $token = $res->content ) =~ s/^access_token=//; + $c->controller('OAuth2')->redirect( $c, error => 'token' ) + unless ($token); + $token =~ s/&.*$//; + my $extra_res = $ua->request( + GET 'https://api.github.com/user', + authorization => "token $token" + ); + my $extra = eval { decode_json( $extra_res->content ) } || {}; + $self->update_user( $c, github => $extra->{id}, $extra ); + } + else { + $c->res->redirect( + 'https://github.com/login/oauth/authorize?client_id=' + . $self->consumer_key ); + } +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Login/Google.pm b/lib/MetaCPAN/Server/Controller/Login/Google.pm new file mode 100644 index 000000000..6d3edf29d --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Login/Google.pm @@ -0,0 +1,64 @@ +package MetaCPAN::Server::Controller::Login::Google; + +use strict; +use warnings; + +use Cpanel::JSON::XS qw( decode_json ); +use HTTP::Request::Common qw( GET POST ); +use LWP::UserAgent (); +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller::Login' } + +has [qw( consumer_key consumer_secret )] => ( + is => 'ro', + required => 1, +); + +sub index : Path Args(0) { + my ( $self, $c ) = @_; + my $req = $c->req; + + if ( my $code = $c->req->params->{code} ) { + my $ua = LWP::UserAgent->new; + my $token_res = $ua->request( + POST 'https://accounts.google.com/o/oauth2/token', + [ + code => $code, + client_id => $self->consumer_key, + client_secret => $self->consumer_secret, + redirect_uri => $c->uri_for( $self->action_for('index') ), + grant_type => 'authorization_code', + ] + ); + + my $token_info = eval { decode_json( $token_res->content ) } || {}; + + $c->controller('OAuth2') + ->redirect( $c, error => $token_info->{error} ) + if defined $token_info->{error}; + + my $token = $token_info->{access_token}; + $c->controller('OAuth2')->redirect( $c, error => 'token' ) + unless $token; + + my $user_res + = $ua->request( GET + "https://www.googleapis.com/oauth2/v1/userinfo?access_token=$token" + ); + my $user = eval { decode_json( $user_res->content ) } || {}; + $self->update_user( $c, google => $user->{id}, $user ); + } + else { + my $url = URI->new('https://accounts.google.com/o/oauth2/auth'); + $url->query_form( + client_id => $self->consumer_key, + response_type => 'code', + redirect_uri => $c->uri_for( $self->action_for('index') ), + scope => 'https://www.googleapis.com/auth/userinfo.profile', + ); + $c->res->redirect($url); + } +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Login/PAUSE.pm b/lib/MetaCPAN/Server/Controller/Login/PAUSE.pm new file mode 100644 index 000000000..ebce10c14 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Login/PAUSE.pm @@ -0,0 +1,65 @@ +package MetaCPAN::Server::Controller::Login::PAUSE; + +use Moose; +use namespace::autoclean; + +use CHI (); +use Log::Contextual qw( :log :dlog ); +use MetaCPAN::Model::Email::PAUSE (); +use MetaCPAN::Util qw( generate_sid ); + +BEGIN { extends 'MetaCPAN::Server::Controller::Login' } + +has cache => ( + is => 'ro', + isa => 'CHI::Driver', + builder => '_build_cache', +); + +sub _build_cache { + CHI->new( + driver => 'File', + root_dir => 'var/tmp/cache', + ); +} + +sub index : Path Args(0) { + my ( $self, $c ) = @_; + my $code = $c->req->params->{code}; + my $id; + if ( $code + && ( $id = $self->cache->get($code) ) ) + { + $self->cache->remove($code); + $self->update_user( $c, pause => $id, {} ); + + } + elsif ( ( $id = $c->req->parameters->{id} ) + && $c->req->parameters->{id} =~ /[a-zA-Z]+/ ) + { + my $author = $c->model('ESModel')->doc('author')->get( uc($id) ); + $c->controller('OAuth2')->redirect( $c, error => "author_not_found" ) + unless ($author); + + my $code = generate_sid(); + $self->cache->set( $code, $author->pauseid, 86400 ); + + my $url = $c->request->uri->clone; + $url->query("code=$code"); + + my $email = MetaCPAN::Model::Email::PAUSE->new( + author => $author, + url => $url, + ); + + my $sent = $email->send; + + if ( !$sent ) { + log_error { 'Could not send PAUSE email to ' . $author->pauseid }; + } + + $c->controller('OAuth2')->redirect( $c, success => 'mail_sent' ); + } +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Login/Twitter.pm b/lib/MetaCPAN/Server/Controller/Login/Twitter.pm new file mode 100644 index 000000000..2bb917f61 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Login/Twitter.pm @@ -0,0 +1,71 @@ +package MetaCPAN::Server::Controller::Login::Twitter; + +use Moose; + +use Twitter::API (); + +BEGIN { extends 'MetaCPAN::Server::Controller::Login' } + +has [qw(consumer_key consumer_secret)] => ( + is => 'ro', + required => 1, +); + +sub nt { + my $self = shift; + Twitter::API->new_with_traits( + api_version => '2', + consumer_key => $self->consumer_key, + consumer_secret => $self->consumer_secret, + ); +} + +sub index : Path Args(0) { + my ( $self, $c ) = @_; + my $req = $c->req; + + # Ensure a session is created so it can be used for writing and reading + # Twitter credentials to use for the OAuth flow. + $c->session; + + if ( my $code = $req->parameters->{oauth_verifier} ) { + my $nt = $self->nt; + my $response = $nt->oauth_access_token( + token => $c->session->{oauth_token}, + token_secret => $c->session->{oauth_token_secret}, + verifier => $code, + ); + + $c->controller('OAuth2')->redirect( $c, error => 'token' ) + unless ( $response->{oauth_token_secret} ); + + $self->update_user( + $c, + twitter => $response->{user_id}, + { + id => $response->{user_id}, + name => $response->{screen_name}, + } + ); + } + elsif ( $req->params->{denied} ) { + $c->controller('OAuth2')->redirect( $c, error => 'denied' ); + } + else { + my $nt = $self->nt; + my $response = $nt->oauth_request_token( + callback => $c->uri_for( $self->action_for('index') ) ); + my $url = $nt->oauth_authorization_url( { + oauth_token => $response->{oauth_token}, + } ); + + $c->session( + oauth_token => $response->{oauth_token}, + oauth_token_secret => $response->{oauth_token_secret}, + ); + + $c->res->redirect($url); + } +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Mirror.pm b/lib/MetaCPAN/Server/Controller/Mirror.pm new file mode 100644 index 000000000..38a2eefff --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Mirror.pm @@ -0,0 +1,18 @@ +package MetaCPAN::Server::Controller::Mirror; + +use strict; +use warnings; + +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +sub search : Path('search') : Args(0) { + my ( $self, $c ) = @_; + $c->stash_or_detach( + $c->model('ESQuery')->mirror->search( $c->req->param('q') ) ); +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Module.pm b/lib/MetaCPAN/Server/Controller/Module.pm new file mode 100644 index 000000000..db1b60dfd --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Module.pm @@ -0,0 +1,24 @@ +package MetaCPAN::Server::Controller::Module; + +use strict; +use warnings; + +use Moose; +use MetaCPAN::Util qw( single_valued_arrayref_to_scalar ); + +BEGIN { extends 'MetaCPAN::Server::Controller::File' } + +has '+type' => ( default => 'file' ); + +sub get : Path('') : Args(1) { + my ( $self, $c, $name ) = @_; + my $file + = $c->model('ESQuery')->file->find_module( $name, $c->req->fields ); + if ( !defined $file ) { + $c->detach( '/not_found', [] ); + } + $c->stash($file); +} + +__PACKAGE__->meta->make_immutable(); +1; diff --git a/lib/MetaCPAN/Server/Controller/OAuth2.pm b/lib/MetaCPAN/Server/Controller/OAuth2.pm new file mode 100644 index 000000000..1e04049ab --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/OAuth2.pm @@ -0,0 +1,142 @@ +package MetaCPAN::Server::Controller::OAuth2; + +use Moose; +BEGIN { extends 'Catalyst::Controller' } + +use Cpanel::JSON::XS qw( decode_json encode_json ); +use Digest::SHA (); +use MetaCPAN::Util qw( true false ); +use URI (); + +has login => ( is => 'ro' ); +has clients => ( is => 'ro' ); + +sub COMPONENT { + my $self = shift; + my ( $app, $config ) = @_; + $config = $self->merge_config_hashes( $app->config->{'OAuth2::Provider'}, + $config ); + return $self->SUPER::COMPONENT( $app, $config ); +} + +sub authorize : Local { + my ( $self, $c ) = @_; + my $params = $c->req->query_parameters; + if ( + $params->{choice} + && ( !$c->user_exists + || $c->user_exists + && !$c->user->has_identity( $params->{choice} ) ) + ) + { + $c->res->redirect( + $c->uri_for( "/login/$params->{choice}", $params ) ); + $c->detach; + } + elsif ( !$c->user_exists ) { + $c->res->redirect( $c->uri_for( "/login", $params ) ); + $c->detach; + } + my ( $response_type, $client_id, $redirect_uri, $scope, $state ) + = @$params{qw(response_type client_id redirect_uri scope state)}; + $self->redirect( $c, error => 'invalid_request' ) + unless ($client_id); + $self->redirect( $c, error => 'unauthorized_client' ) + unless ( $self->clients->{$client_id} ); + $redirect_uri = $self->clients->{$client_id}->{redirect_uri}->[0]; + $self->redirect( $c, error => 'invalid_request' ) + unless ($redirect_uri); + $response_type ||= 'code'; + my $uri = URI->new($redirect_uri); + my $code = $self->_build_code; + $uri->query_form( { code => $code, $state ? ( state => $state ) : () } ); + $c->user->_set_code($code); + $c->user->put( { refresh => true } ); + $c->res->redirect($uri); +} + +sub access_token : Local { + my ( $self, $c ) = @_; + my $params = $c->req->query_parameters; + my ( $grant_type, $client_id, $code, $redirect_uri, $client_secret ) + = @$params{qw(grant_type client_id code redirect_uri client_secret)}; + $grant_type ||= 'authorization_code'; + $self->bad_request( $c, + invalid_request => 'client_id query parameter is required' ) + unless ($client_id); + $self->bad_request( $c, + unauthorized_client => 'client_id does not exist' ) + unless ( $self->clients->{$client_id} ); + $self->bad_request( $c, + unauthorized_client => 'client_secret does not match' ) + unless ( $self->clients->{$client_id}->{secret} eq $client_secret ); + + $redirect_uri = $self->clients->{$client_id}->{redirect_uri}->[0]; + $self->bad_request( $c, + invalid_request => 'redirect_uri query parameter is required' ) + unless ($redirect_uri); + $self->bad_request( $c, + invalid_request => 'code query parameter is required' ) + unless ($code); + my $user = $c->model('ESModel')->doc('account')->find_code($code); + $self->bad_request( $c, access_denied => 'the code is invalid' ) + unless ($user); + + my ($access_token) = map { $_->{token} } + grep { $_->{client} eq $client_id } @{ $user->access_token }; + unless ($access_token) { + $access_token = $self->_build_code; + $user->add_access_token( + { token => $access_token, client => $client_id } ); + } + $user->clear_token; + $user->put( { refresh => true } ); + + $c->res->content_type('application/json'); + $c->res->body( encode_json( + { access_token => $access_token, token_type => 'bearer' } + ) ); + +} + +sub bad_request { + my ( $self, $c, $type, $message ) = @_; + $c->res->code(500); + $c->res->content_type('application/json'); + $c->res->body( + encode_json( { error => $type, error_description => $message } ) ); + $c->detach; +} + +sub _build_code { + my $digest = Digest::SHA::sha1_base64( rand() . $$ . {} . time ); + $digest =~ tr{+/}{-_}; + return $digest; +} + +sub redirect { + my ( $self, $c, $type, $message ) = @_; + my $clients = $self->clients; + my $params = $c->req->params; + if ( my $cid = $c->req->cookie('oauth_tmp') ) { + eval { $params = decode_json( $cid->value ) }; + $cid->expires('-1y'); + $c->res->cookies->{oauth_tmp} = $cid; + } + my ( $client, $redirect_uri ) = @$params{qw(client_id redirect_uri)}; + + # we don't trust the user's redirect uri + $redirect_uri = $self->clients->{$client}->{redirect_uri}->[0] + if ($client); + + if ($redirect_uri) { + $c->res->redirect( $redirect_uri . "?$type=$message" ); + } + else { + $c->res->body( encode_json( { $type => $message } ) ); + $c->res->content_type('application/json'); + } + $c->detach; +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Package.pm b/lib/MetaCPAN/Server/Controller/Package.pm new file mode 100644 index 000000000..c58a49c1a --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Package.pm @@ -0,0 +1,23 @@ +package MetaCPAN::Server::Controller::Package; + +use Moose; +use namespace::autoclean; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +# https://fastapi.metacpan.org/v1/package/modules/Moose +sub modules : Path('modules') : Args(1) { + my ( $self, $c, $dist ) = @_; + + my $last = $c->model('ESQuery')->release->find($dist); + $c->detach( '/not_found', ["Cannot find last release for $dist"] ) + unless $last; + $c->stash_or_detach( + $c->model('ESQuery')->package->get_modules( $dist, $last->{version} ) + ); +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Server/Controller/Permission.pm b/lib/MetaCPAN/Server/Controller/Permission.pm new file mode 100644 index 000000000..3eb3ff37d --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Permission.pm @@ -0,0 +1,29 @@ +package MetaCPAN::Server::Controller::Permission; + +use Moose; +use namespace::autoclean; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +sub by_author : Path('by_author') : Args(1) { + my ( $self, $c, $pauseid ) = @_; + $c->stash_or_detach( + $c->model('ESQuery')->permission->by_author($pauseid) ); +} + +sub by_module : Path('by_module') : Args(1) { + my ( $self, $c, $module ) = @_; + $c->stash_or_detach( + $c->model('ESQuery')->permission->by_modules($module) ); +} + +sub by_modules : Path('by_module') : Args(0) { + my ( $self, $c ) = @_; + $c->stash_or_detach( $c->model('ESQuery') + ->permission->by_modules( $c->read_param('module') ) ); +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Server/Controller/Pod.pm b/lib/MetaCPAN/Server/Controller/Pod.pm new file mode 100644 index 000000000..d7daf60d7 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Pod.pm @@ -0,0 +1,90 @@ +package MetaCPAN::Server::Controller::Pod; + +use strict; +use warnings; + +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +sub find : Path('') { + my ( $self, $c, $author, $release, @path ) = @_; + + # $c->add_author_key($author) called from /source/get request below + $c->cdn_max_age('1y'); + + my $q = $c->req->query_params; + for my $opt (qw(show_errors url_prefix)) { + $c->stash->{$opt} = $q->{$opt} + if exists $q->{$opt}; + } + + $c->stash->{link_mappings} + = $self->find_dist_links( $c, $author, $release, !!$q->{permalinks} ); + + $c->forward( '/source/get', [ $author, $release, @path ] ); + my $path = $c->stash->{path}; + $c->detach( '/bad_request', ['Requested resource is a binary file'] ) + if ( -B $path ); + $c->detach( '/bad_request', + ['Requested resource is too large to be processed'] ) + if ( $path->stat->size > 2**21 ); + $c->forward( $c->view('Pod') ); +} + +sub get : Path('') : Args(1) { + my ( $self, $c, $module ) = @_; + $module = $c->model('ESQuery')->file->find_pod($module) + or $c->detach( '/not_found', [] ); + $c->forward( 'find', [ map { $module->{$_} } qw(author release path) ] ); +} + +sub find_dist_links { + my ( $self, $c, $author, $release, $permalinks ) = @_; + my $modules + = $c->model('ESQuery')->file->documented_modules( $author, $release ); + my $files = $modules->{files}; + + my $links = {}; + + for my $file (@$files) { + my $name = $file->{documentation} + or next; + my ($module) + = grep { $_->{name} eq $name } @{ $file->{module} }; + if ( $module && $module->{authorized} && $module->{indexed} ) { + if ($permalinks) { + $links->{$name} = join '/', + 'release', $author, $release, $file->{path}; + } + else { + $links->{$name} = $name; + } + } + next + if exists $links->{$name}; + if ($permalinks) { + $links->{$name} = join '/', + 'release', $author, $release, $file->{path}; + } + else { + $links->{$name} = join '/', + 'distribution', $file->{distribution}, $file->{path}; + } + } + return $links; +} + +sub render : Path('/pod_render') Args(0) { + my ( $self, $c ) = @_; + my $pod = $c->req->parameters->{pod}; + my $show_errors = !!$c->req->parameters->{show_errors}; + $c->res->content_type('text/x-pod'); + $c->res->body($pod); + $c->stash( { show_errors => $show_errors } ); + $c->forward( $c->view('Pod') ); +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Rating.pm b/lib/MetaCPAN/Server/Controller/Rating.pm new file mode 100644 index 000000000..bc80b85f3 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Rating.pm @@ -0,0 +1,78 @@ +package MetaCPAN::Server::Controller::Rating; + +use strict; +use warnings; + +use MetaCPAN::Util qw( true false ); +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +sub by_distributions : Path('by_distributions') : Args(0) { + my ( $self, $c ) = @_; + $c->stash_or_detach( { + took => 0, + total => 0, + distributions => {}, + } ); +} + +sub get : Path('') : Args(1) { + my ( $self, $c ) = @_; + $c->detach('/not_found'); +} + +sub _mapping : Path('_mapping') : Args(0) { + my ( $self, $c ) = @_; + $c->detach('/not_found'); +} + +sub find : Path('_search') : Args(0) : ActionClass('~Deserialize') { + my ( $self, $c, $scroll ) = @_; + + my @hits; + + # fake results for MetaCPAN::Client so it doesn't fail its tests + if ( ( $c->req->user_agent // '' ) =~ m{^MetaCPAN::Client/([0-9.]+)} ) { + if ( $1 <= 2.031001 ) { + my $query = $c->req->data->{'query'}; + if ( $query + && $query->{term} + && ( $query->{term}{distribution} // '' ) eq 'Moose' ) + { + + push @hits, + { + _source => { + distribution => "Moose" + }, + }; + } + } + } + + $c->stash_or_detach( { + $c->req->param('scroll') ? ( _scroll_id => 'FAKE_SCROLL_ID' ) : (), + _shards => { + failed => 0, + successful => 0, + total => 0, + }, + hits => { + hits => \@hits, + max_score => undef, + total => scalar @hits, + }, + timed_out => false, + took => 0, + } ); +} + +sub all : Path('') : Args(0) : ActionClass('~Deserialize') { + my ( $self, $c ) = @_; + $c->forward('find'); +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Release.pm b/lib/MetaCPAN/Server/Controller/Release.pm new file mode 100644 index 000000000..b71b9bade --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Release.pm @@ -0,0 +1,133 @@ +package MetaCPAN::Server::Controller::Release; + +use strict; +use warnings; + +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +sub find : Path('') : Args(1) { + my ( $self, $c, $name ) = @_; + my $file = $c->model('ESQuery')->release->find($name); + $c->detach( '/not_found', [] ) unless $file; + $c->stash($file); +} + +sub get : Path('') : Args(2) { + my ( $self, $c, $author, $name ) = @_; + $c->add_author_key($author); + $c->cdn_max_age('1y'); + $c->stash_or_detach( + $c->model('ESQuery')->release->by_author_and_name( $author, $name ) ); +} + +sub contributors : Path('contributors') : Args(2) { + my ( $self, $c, $author, $release ) = @_; + $c->stash_or_detach( + $c->model('ESQuery')->release->get_contributors( $author, $release ) + ); +} + +sub files : Path('files') : Args(1) { + my ( $self, $c, $name ) = @_; + my $files = $c->req->params->{files}; + $c->detach( '/bad_request', ['No files requested'] ) unless $files; + $c->stash_or_detach( $c->model('ESQuery') + ->release->get_files( $name, [ split /,/, $files ] ) ); +} + +sub modules : Path('modules') : Args(2) { + my ( $self, $c, $author, $name ) = @_; + $c->stash_or_detach( + $c->model('ESQuery')->release->modules( $author, $name ) ); +} + +sub recent : Path('recent') : Args(0) { + my ( $self, $c ) = @_; + my $params = $c->req->params; + my $type = $params->{type}; + my $page = $params->{page}; + my $size = $params->{page_size}; + $c->stash_or_detach( + $c->model('ESQuery')->release->recent( $type, $page, $size ) ); +} + +sub by_author : Path('by_author') : Args(1) { + my ( $self, $c, $pauseid ) = @_; + my $params = $c->req->params; + my $page = $params->{page}; + my $size = $params->{page_size} // $params->{size}; + $c->stash_or_detach( + $c->model('ESQuery')->release->by_author( $pauseid, $page, $size ) ); +} + +sub latest_by_distribution : Path('latest_by_distribution') : Args(1) { + my ( $self, $c, $dist ) = @_; + $c->add_dist_key($dist); + $c->cdn_max_age('1y'); + $c->stash_or_detach( + $c->model('ESQuery')->release->latest_by_distribution($dist) ); +} + +sub latest_by_author : Path('latest_by_author') : Args(1) { + my ( $self, $c, $pauseid ) = @_; + $c->stash_or_detach( + $c->model('ESQuery')->release->latest_by_author($pauseid) ); +} + +sub all_by_author : Path('all_by_author') : Args(1) { + my ( $self, $c, $pauseid ) = @_; + my $params = $c->req->params; + my $page = $params->{page}; + my $size = $params->{page_size}; + $c->stash_or_detach( + $c->model('ESQuery')->release->all_by_author( $pauseid, $page, $size ) + ); +} + +sub versions : Path('versions') : Args(1) { + my ( $self, $c, $dist ) = @_; + my %params = %{ $c->req->params }{qw( plain versions )}; + $c->add_dist_key($dist); + $c->cdn_max_age('1y'); + my $data = $c->model('ESQuery') + ->release->versions( $dist, [ split /,/, $params{versions} || '' ] ); + + if ( $params{plain} ) { + my $data = join "\n", + map { join "\t", @{$_}{qw/ version download_url /} } + @{ $data->{releases} }; + $c->res->body($data); + $c->res->content_type('text/plain'); + } + else { + $c->stash_or_detach($data); + } +} + +sub top_uploaders : Path('top_uploaders') : Args() { + my ( $self, $c ) = @_; + my $range = $c->req->param('range') || 'weekly'; + $c->stash_or_detach( + $c->model('ESQuery')->release->top_uploaders($range) ); +} + +sub interesting_files : Path('interesting_files') : Args(2) { + my ( $self, $c, $author, $release ) = @_; + my $categories = $c->read_param( 'category', 1 ); + $c->stash_or_detach( $c->model('ESQuery') + ->file->interesting_files( $author, $release, $categories ) ); +} + +sub files_by_category : Path('files_by_category') : Args(2) { + my ( $self, $c, $author, $release ) = @_; + my $categories = $c->read_param( 'category', 1 ); + $c->stash_or_detach( $c->model('ESQuery') + ->file->files_by_category( $author, $release, $categories ) ); +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Server/Controller/ReverseDependencies.pm b/lib/MetaCPAN/Server/Controller/ReverseDependencies.pm new file mode 100644 index 000000000..e2749b499 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/ReverseDependencies.pm @@ -0,0 +1,32 @@ +package MetaCPAN::Server::Controller::ReverseDependencies; + +use strict; +use warnings; + +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +__PACKAGE__->config( namespace => 'reverse_dependencies' ); + +with 'MetaCPAN::Server::Role::JSONP'; + +sub dist : Path('dist') : Args(1) { + my ( $self, $c, $dist ) = @_; + $c->stash_or_detach( + $c->model('ESQuery')->release->reverse_dependencies( + $dist, @{ $c->req->params }{qw< page page_size sort >} + ) + ); +} + +sub module : Path('module') : Args(1) { + my ( $self, $c, $module ) = @_; + $c->stash_or_detach( + $c->model('ESQuery')->release->requires( + $module, @{ $c->req->params }{qw< page page_size sort >} + ) + ); +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Root.pm b/lib/MetaCPAN/Server/Controller/Root.pm new file mode 100644 index 000000000..a7f0d4fe9 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Root.pm @@ -0,0 +1,111 @@ +package MetaCPAN::Server::Controller::Root; + +use strict; +use warnings; + +use Moose; +use MetaCPAN::Util qw( true ); + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +__PACKAGE__->config( namespace => '' ); + +# This will catch anything that isn't matched by another route. +sub default : Path { + my ( $self, $c ) = @_; + $c->forward( '/not_found', [] ); +} + +# handle / +sub all : Path('') : Args(0) { + my ( $self, $c ) = @_; + $c->res->redirect( + 'https://github.com/metacpan/metacpan-api/blob/master/docs/API-docs.md', + 302 + ); +} + +# The parent class has a sub with this signature but expects a namespace +# and an es type... since this controller doesn't have those, just overwrite. +sub get : Path('') : Args(1) { + my ( $self, $c ) = @_; + $c->forward( '/not_found', [] ); +} + +sub not_found : Private { + my ( $self, $c, @params ) = @_; + my $message = join( '/', @params ); + + # XXX fix me + # $c->clear_stash; + $c->stash( { code => 404, message => $message || "Not found" } ); + $c->response->status(404); + $c->forward( $c->view('JSON') ); +} + +sub not_allowed : Private { + my ( $self, $c, $message ) = @_; + + # XXX fix me + # $c->clear_stash; + $c->stash( { message => $message || 'Not allowed' } ); + $c->response->status(403); + $c->forward( $c->view('JSON') ); +} + +sub bad_request : Private { + my ( $self, $c, $message, $code ) = @_; + + # XXX fix me + # $c->clear_stash; + $c->stash( { message => $message || 'Bad request' } ); + $c->response->status( $code || 400 ); + $c->forward( $c->view('JSON') ); +} + +sub robots : Path("robots.txt") Args(0) { + my ( $self, $c ) = @_; + $c->res->content_type("text/plain"); + $c->res->body("User-agent: *\nDisallow: /\n"); +} + +sub healthcheck : Local Args(0) { + my ( $self, $c ) = @_; + $c->stash( { success => true } ); + $c->forward( $c->view('JSON') ); +} + +sub end : ActionClass('RenderView') { + my ( $self, $c ) = @_; + if ( $c->controller->does('MetaCPAN::Server::Role::JSONP') + && $c->controller->enable_jsonp ) + { + + # See also: http://www.w3.org/TR/cors/ + if ( my $origin = $c->req->header('Origin') ) { + $c->res->header( 'Access-Control-Allow-Origin' => $origin ); + } + + # call default view unless body has been set + $c->forward( $c->view ) unless ( $c->res->body ); + $c->forward( $c->view('JSONP') ); + $c->res->content_type('text/javascript') + if ( $c->req->params->{callback} ); + } + + if ( $c->cdn_max_age ) { + + # If we allow caching, we can serve stale content, if we error + # on backend. Because we have caching on our UI (metacpan.org) + # we don't really want to use stale_while_revalidate on + # our API as otherwise the UI cacheing could be of old content + $c->cdn_stale_if_error('1M'); + } + else { + # Default to telling fastly NOT to cache unless we have a + # cdn cache time + $c->cdn_never_cache(1); + } +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Scroll.pm b/lib/MetaCPAN/Server/Controller/Scroll.pm new file mode 100644 index 000000000..1c3bcbcef --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Scroll.pm @@ -0,0 +1,67 @@ +package MetaCPAN::Server::Controller::Scroll; + +use strict; +use warnings; +use namespace::autoclean; + +use MetaCPAN::Util qw(true false); +use Moose; +use Try::Tiny qw( catch try ); + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +sub index : Path('/_search/scroll') : Args { + my ( $self, $c, $scroll_id ) = @_; + my $req = $c->req; + + if ( !defined($scroll_id) ) { + try { + if ( my $qs_id = $req->param('scroll_id') ) { + $scroll_id = $qs_id; + } + else { + # Is this the best way to get the body content? + my $body = $req->body; + $scroll_id = do { local $/; $body->getline } + if ref $body; + } + die "Scroll Id required\n" unless defined($scroll_id); + } + catch { + chomp( my $e = $_[0] ); + $self->internal_error( $c, $e ); + }; + } + + if ( $scroll_id eq 'FAKE_SCROLL_ID' ) { + $c->stash( { + _scroll_id => $scroll_id, + _shards => { + failed => 0, + successful => 0, + total => 0, + }, + hits => { + hits => [], + max_score => undef, + total => 0, + }, + timed_out => false, + took => 0, + } ); + return; + } + + my $res = eval { + $c->model('ES')->scroll( { + scroll_id => $scroll_id, + scroll => $c->req->params->{scroll}, + } ); + } or do { $self->internal_error( $c, $@ ); }; + + $c->stash($res); +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Search.pm b/lib/MetaCPAN/Server/Controller/Search.pm new file mode 100644 index 000000000..bc150c1b8 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Search.pm @@ -0,0 +1,15 @@ +package MetaCPAN::Server::Controller::Search; + +use strict; +use warnings; + +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +sub index : Chained('/') : PathPart('search') : CaptureArgs(0) { +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Search/Autocomplete.pm b/lib/MetaCPAN/Server/Controller/Search/Autocomplete.pm new file mode 100644 index 000000000..88e8e87b2 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Search/Autocomplete.pm @@ -0,0 +1,26 @@ +package MetaCPAN::Server::Controller::Search::Autocomplete; + +use strict; +use warnings; + +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +has '+type' => ( default => 'file' ); + +sub get : Local : Path('') : Args(0) { + my ( $self, $c ) = @_; + $c->stash_or_detach( + $c->model('ESQuery')->file->autocomplete( $c->req->param("q") ) ); +} + +sub suggest : Local : Path('/suggest') : Args(0) { + my ( $self, $c ) = @_; + $c->stash_or_detach( $c->model('ESQuery') + ->file->autocomplete_suggester( $c->req->param("q") ) ); +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Search/DownloadURL.pm b/lib/MetaCPAN/Server/Controller/Search/DownloadURL.pm new file mode 100644 index 000000000..e04c1d4e7 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Search/DownloadURL.pm @@ -0,0 +1,24 @@ +package MetaCPAN::Server::Controller::Search::DownloadURL; + +use strict; +use warnings; + +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +has '+type' => ( default => 'file' ); + +sub get : Local : Path('/download_url') : Args(1) { + my ( $self, $c, $module ) = @_; + my $type = $module eq 'perl' ? 'dist' : 'module'; + my $data + = $c->model('ESQuery') + ->release->find_download_url( $type, $module, $c->req->params ); + return $c->detach( '/not_found', [] ) unless $data; + $c->stash($data); +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Search/History.pm b/lib/MetaCPAN/Server/Controller/Search/History.pm new file mode 100644 index 000000000..87f0851ee --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Search/History.pm @@ -0,0 +1,22 @@ +package MetaCPAN::Server::Controller::Search::History; + +use strict; +use warnings; + +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +has '+type' => ( default => 'file' ); + +sub get : Local : Path('') : Args { + my ( $self, $c, $type, $name, @path ) = @_; + my $fields = $c->res->fields; + my $data = $c->model('ESQuery') + ->file->history( $type, $name, \@path, { fields => $fields } ); + $c->stash($data); +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Search/Web.pm b/lib/MetaCPAN/Server/Controller/Search/Web.pm new file mode 100644 index 000000000..45a2d69f2 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Search/Web.pm @@ -0,0 +1,44 @@ +package MetaCPAN::Server::Controller::Search::Web; + +use strict; +use warnings; + +use Moose; + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +# Kill default actions provided by our stupid Controller base class +sub get { } +sub all { } + +# returns the contents of the first result of a query +sub first : Chained('/search/index') : PathPart('first') : Args(0) { + my ( $self, $c ) = @_; + my $args = $c->req->params; + + my $model = $c->model('Search'); + my $results = $model->search_for_first_result( $args->{q} ); + + $c->stash_or_detach($results); +} + +# The web endpoint is the primary one, this handles the front-end's user-facing search + +sub web : Chained('/search/index') : PathPart('web') : Args(0) { + my ( $self, $c ) = @_; + my $args = $c->req->params; + + my $query = $args->{q}; + my $size = $args->{page_size} // $args->{size} // 20; + my $page = $args->{page} // ( 1 + int( ( $args->{from} // 0 ) / $size ) ); + my $collapsed = $args->{collapsed}; + + my $model = $c->model('Search'); + my $results = $model->search_web( $query, $page, $size, $collapsed ); + + $c->stash($results); +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/Source.pm b/lib/MetaCPAN/Server/Controller/Source.pm new file mode 100644 index 000000000..8144782d5 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/Source.pm @@ -0,0 +1,72 @@ +package MetaCPAN::Server::Controller::Source; + +use strict; +use warnings; + +use Moose; +use Plack::App::Directory (); +use Plack::MIME (); + +BEGIN { extends 'MetaCPAN::Server::Controller' } + +with 'MetaCPAN::Server::Role::JSONP'; + +sub index : Chained('/') : PathPart('source') : CaptureArgs(0) { +} + +# e.g. /source/DOY/Moose-0.01/MANIFEST or /source/DOY/Moose-0.01/ +sub get : Chained('index') : PathPart('') : Args { + my ( $self, $c, $author, $release, @path ) = @_; + + $c->add_author_key($author); + $c->cdn_max_age('1y'); + + my $file = $c->model('Source')->path( $author, $release, @path ) + or $c->detach( '/not_found', [] ); + if ( $file->is_dir ) { + my $path = '/source/' . join( '/', $author, $release, @path ); + my $env = $c->req->env; + local $env->{PATH_INFO} = '/'; + local $env->{SCRIPT_NAME} = $path; + my $res = Plack::App::Directory->new( { root => $file->stringify } ) + ->to_app->($env); + + $c->res->content_type('text/html'); + $c->res->body( $res->[2]->[0] ); + } + else { + $c->stash->{path} = $file; + + # Add X-Content-Type header, for fastly to rewrite on st.aticpan.org + my $type = Plack::MIME->mime_type($file) || 'text/plain'; + $c->res->header( 'X-Content-Type' => $type ); + if ( $type =~ m{^image/} ) { + $c->res->content_type($type); + } + elsif ( $type + =~ m{^(?:text/.*|application/javascript|application/json)$} ) + { + $c->res->content_type('text/plain'); + } + else { + $c->res->content_type('application/octet-stream'); + } + $c->res->body( $file->openr ); + } +} + +# e.g. /source/Moose +sub module : Chained('index') : PathPart('') : Args(1) { + my ( $self, $c, $module ) = @_; + + $c->cdn_never_cache(1); + + my $file + = $c->model('ESQuery') + ->file->find_module( $module, [qw(author release path)] ) + or $c->detach( '/not_found', [] ); + + $c->forward( 'get', [ map { $file->{$_} } qw(author release path) ] ); +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/User.pm b/lib/MetaCPAN/Server/Controller/User.pm new file mode 100644 index 000000000..3391ea0c5 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/User.pm @@ -0,0 +1,122 @@ +package MetaCPAN::Server::Controller::User; + +use strict; +use warnings; + +use DateTime (); +use MetaCPAN::Util qw( true false ); +use Moose; +use Log::Log4perl::MDC (); + +BEGIN { extends 'Catalyst::Controller::REST' } + +with 'MetaCPAN::Role::Fastly'; + +__PACKAGE__->config( + json_options => { relaxed => 1, allow_nonref => 1 }, + default => 'text/html', + map => { 'text/html' => [qw(View JSON)] }, +); + +sub auto : Private { + my ( $self, $c ) = @_; + + $c->cdn_never_cache(1); + + if ( my $token = $c->req->params->{access_token} ) { + if ( my $user + = $c->model('ESModel')->doc('account')->find_token($token) ) + { + $c->authenticate( { user => $user } ); + Log::Log4perl::MDC->put( user => $user->id ); + } + } + return $c->user_exists; +} + +sub index : Path Args(0) { + my ( $self, $c ) = @_; + $c->stash( $c->user->data ); + delete $c->stash->{code}; + $c->detach( $c->view('JSON') ); +} + +sub identity : Local : ActionClass('REST') { +} + +sub identity_GET { + my ( $self, $c ) = @_; + my ($identity) = @{ $c->req->arguments }; + ($identity) + = grep { $_->{name} eq $identity } @{ $c->user->data->{identity} }; + $identity + ? $self->status_ok( $c, entity => $identity ) + : $self->status_not_found( $c, message => 'Identity doesn\'t exist' ); +} + +sub identity_DELETE { + my ( $self, $c ) = @_; + my ($identity) = @{ $c->req->arguments }; + my $user = $c->user; + if ( $user->has_identity($identity) ) { + my $id = $user->remove_identity($identity); + $user->put( { refresh => true } ); + $self->status_ok( $c, entity => $id ); + } + else { + $self->status_not_found( $c, message => 'Identity doesn\'t exist' ); + } +} + +sub profile : Local : ActionClass('REST') { + my ( $self, $c ) = @_; + my ($pause) = $c->user->get_identities('pause'); + unless ($pause) { + $self->status_not_found( $c, message => 'Profile doesn\'t exist' ); + $c->detach; + } + my $profile + = $c->model('ESModel')->doc('author')->raw->get( $pause->key ); + $c->stash->{profile} = $profile->{_source}; +} + +sub profile_GET { + my ( $self, $c ) = @_; + $self->status_ok( $c, entity => $c->stash->{profile} ); +} + +sub profile_PUT { + my ( $self, $c ) = @_; + my $profile = $c->stash->{profile}; + + map { + defined $c->req->data->{$_} + ? $profile->{$_} = $c->req->data->{$_} + : delete $profile->{$_} + } qw(name asciiname website email + gravatar_url profile blog + donation city region country + location extra perlmongers); + $profile->{updated} = DateTime->now->iso8601; + my @errors = $c->model('ESModel')->doc('author') + ->new_document->validate($profile); + + if (@errors) { + $self->status_bad_request( $c, message => 'Validation failed' ); + $c->stash->{rest}->{errors} = \@errors; + } + else { + $profile + = $c->model('ESModel')->doc('author') + ->put( $profile, { refresh => true } ); + $self->status_created( + $c, + location => $c->uri_for( '/author/' . $profile->{pauseid} ), + entity => $profile->meta->get_data($profile) + ); + $self->purge_author_key( $profile->{pauseid} ); + } + +} + +1; diff --git a/lib/MetaCPAN/Server/Controller/User/Favorite.pm b/lib/MetaCPAN/Server/Controller/User/Favorite.pm new file mode 100644 index 000000000..bf0aacdb0 --- /dev/null +++ b/lib/MetaCPAN/Server/Controller/User/Favorite.pm @@ -0,0 +1,54 @@ +package MetaCPAN::Server::Controller::User::Favorite; + +use strict; +use warnings; + +use Moose; +use MetaCPAN::Util qw( true false ); + +BEGIN { extends 'Catalyst::Controller::REST' } + +sub index : Path : ActionClass('REST') { +} + +sub index_POST { + my ( $self, $c ) = @_; + my $pause = $c->stash->{pause}; + my $data = $c->req->data; + my $favorite = $c->model('ESModel')->doc('favorite')->put( + { + user => $c->user->id, + author => $data->{author}, + release => $data->{release}, + distribution => $data->{distribution}, + }, + { refresh => true } + ); + $c->purge_author_key( $data->{author} ) if $data->{author}; + $c->purge_dist_key( $data->{distribution} ) if $data->{distribution}; + $self->status_created( + $c, + location => $c->uri_for( join( '/', + '/favorite', $favorite->user, $favorite->distribution ) ), + entity => $favorite->meta->get_data($favorite) + ); +} + +sub index_DELETE { + my ( $self, $c, $distribution ) = @_; + my $favorite = $c->model('ESModel')->doc('favorite') + ->get( { user => $c->user->id, distribution => $distribution } ); + if ($favorite) { + $favorite->delete( { refresh => true } ); + $c->purge_author_key( $favorite->author ) + if $favorite->author; + $c->purge_dist_key($distribution); + $self->status_ok( $c, + entity => $favorite->meta->get_data($favorite) ); + } + else { + $self->status_not_found( $c, message => 'Entity could not be found' ); + } +} + +1; diff --git a/lib/MetaCPAN/Server/Diff.pm b/lib/MetaCPAN/Server/Diff.pm new file mode 100644 index 000000000..eac4e2f9d --- /dev/null +++ b/lib/MetaCPAN/Server/Diff.pm @@ -0,0 +1,112 @@ +package MetaCPAN::Server::Diff; + +use strict; +use warnings; +use Moose; + +use Encoding::FixLatin (); +use File::Spec (); +use IPC::Run3 qw( run3 ); +use MetaCPAN::Types::TypeTiny qw( ArrayRef ); + +has git => ( + is => 'ro', + required => 1, +); + +has [qw(source target)] => ( + is => 'ro', + required => 1, +); + +has raw => ( + is => 'ro', + lazy => 1, + builder => '_build_raw', +); + +has structured => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + builder => '_build_structured', +); + +has numstat => ( + is => 'ro', + writer => '_set_numstat', +); + +has relative => ( + is => 'ro', + required => 1, +); + +sub _build_raw { + my $self = shift; + my $raw = q[]; + run3( + [ + $self->git, + qw(diff --no-renames -z --no-index -u --no-color --numstat), + $self->source, $self->target + ], + undef, + \$raw + ); + ( my $stats = $raw ) =~ s/^([^\n]*\0).*$/$1/s; + $self->_set_numstat($stats); + $raw = substr( $raw, length($stats) ); + return $raw; +} + +# The strings in this hash need to be character strings +# or the json encoder will mojibake them. +# Since the diff could include portions of files in multiple encodings +# try to guess the encoding and upgrade everything to UTF-8. +# It won't be an accurate (binary) representation of the patch +# but that's not what this is used for. +# If we desire such a thing we'd have to base64 encode it or something. + +sub _build_structured { + my $self = shift; + my @structured; + my $raw = $self->raw; # run the builder + + my @raw = split( /\n/, $raw ); + my @lines = split( /\0/, $self->numstat ); + + while ( my $line = shift @lines ) { + my $source = shift @lines; + my $target = shift @lines; + $source = $target if $source eq '/dev/null'; + $target = $source if $target eq '/dev/null'; + $source = File::Spec->abs2rel( $source, $self->relative ); + $target = File::Spec->abs2rel( $target, $self->relative ); + my ( $insertions, $deletions ) = split( /\t/, $line ); + my $segment = q[]; + + while ( my $diff = shift @raw ) { + + # only run it through if non-ascii bytes are found + $diff = Encoding::FixLatin::fix_latin($diff) + if $diff =~ /[^\x00-\x7f]/; + + $segment .= $diff . "\n"; + last if ( $raw[0] && $raw[0] =~ /^diff --git .\//m ); + } + push( + @structured, + { + source => $source, + target => $target, + insertions => $insertions, + deletions => $deletions, + diff => $segment, + } + ); + } + return \@structured; +} + +1; diff --git a/lib/MetaCPAN/Server/Model/ES.pm b/lib/MetaCPAN/Server/Model/ES.pm new file mode 100644 index 000000000..e08b2b4fa --- /dev/null +++ b/lib/MetaCPAN/Server/Model/ES.pm @@ -0,0 +1,25 @@ +package MetaCPAN::Server::Model::ES; + +use Moose; + +use MetaCPAN::Server::Config (); +use MetaCPAN::Types::TypeTiny qw( ES ); + +extends 'Catalyst::Model'; + +has es => ( + is => 'ro', + isa => ES, + coerce => 1, + lazy => 1, + default => sub { + MetaCPAN::Server::Config::config()->{elasticsearch_servers}; + }, +); + +sub ACCEPT_CONTEXT { + my ( $self, $c ) = @_; + return $self->es; +} + +1; diff --git a/lib/MetaCPAN/Server/Model/ESModel.pm b/lib/MetaCPAN/Server/Model/ESModel.pm new file mode 100644 index 000000000..6f27a4052 --- /dev/null +++ b/lib/MetaCPAN/Server/Model/ESModel.pm @@ -0,0 +1,33 @@ +package MetaCPAN::Server::Model::ESModel; + +use Moose; + +use MetaCPAN::Model (); +use MetaCPAN::Model::ESWrapper (); + +extends 'Catalyst::Model'; + +has es => ( + is => 'ro', + writer => '_set_es', +); + +has _esx_model => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + my $es = MetaCPAN::Model::ESWrapper->new( $self->es ); + MetaCPAN::Model->new( es => $es ); + }, +); + +sub ACCEPT_CONTEXT { + my ( $self, $c ) = @_; + if ( !$self->es ) { + $self->_set_es( $c->model('ES') ); + } + return $self->_esx_model; +} + +1; diff --git a/lib/MetaCPAN/Server/Model/ESQuery.pm b/lib/MetaCPAN/Server/Model/ESQuery.pm new file mode 100644 index 000000000..e31615cc4 --- /dev/null +++ b/lib/MetaCPAN/Server/Model/ESQuery.pm @@ -0,0 +1,31 @@ +package MetaCPAN::Server::Model::ESQuery; + +use Moose; + +use MetaCPAN::Query (); + +extends 'Catalyst::Model'; + +has es => ( + is => 'ro', + writer => '_set_es', +); + +has _esx_query => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + MetaCPAN::Query->new( es => $self->es ); + }, +); + +sub ACCEPT_CONTEXT { + my ( $self, $c ) = @_; + if ( !$self->es ) { + $self->_set_es( $c->model('ES') ); + } + return $self->_esx_query; +} + +1; diff --git a/lib/MetaCPAN/Server/Model/Search.pm b/lib/MetaCPAN/Server/Model/Search.pm new file mode 100644 index 000000000..85da37972 --- /dev/null +++ b/lib/MetaCPAN/Server/Model/Search.pm @@ -0,0 +1,34 @@ +package MetaCPAN::Server::Model::Search; + +use strict; +use warnings; + +use Moose; +use MetaCPAN::Query::Search (); + +extends 'Catalyst::Model'; + +has es => ( + is => 'ro', + writer => '_set_es', +); + +has search => ( + is => 'ro', + isa => 'MetaCPAN::Query::Search', + lazy => 1, + default => sub { + my $self = shift; + return MetaCPAN::Query::Search->new( es => $self->es ); + }, +); + +sub ACCEPT_CONTEXT { + my ( $self, $c ) = @_; + if ( !$self->es ) { + $self->_set_es( $c->model('ES') ); + } + return $self->search; +} + +1; diff --git a/lib/MetaCPAN/Server/Model/Source.pm b/lib/MetaCPAN/Server/Model/Source.pm new file mode 100644 index 000000000..8ff322713 --- /dev/null +++ b/lib/MetaCPAN/Server/Model/Source.pm @@ -0,0 +1,192 @@ +package MetaCPAN::Server::Model::Source; +use strict; +use warnings; + +use Archive::Any (); +use MetaCPAN::Types::TypeTiny qw( Path Uri ); +use MetaCPAN::Util (); +use Moose; +use Path::Tiny (); + +extends 'Catalyst::Model'; + +has base_dir => ( + is => 'ro', + isa => Path, + coerce => 1, + default => 'var/tmp/source', +); + +has cpan => ( + is => 'ro', + isa => Path, + coerce => 1, +); + +has remote_cpan => ( + is => 'ro', + isa => Uri, + coerce => 1, +); + +has es_query => ( + is => 'ro', + writer => '_set_es_query', +); + +has http_cache_dir => ( + is => 'ro', + isa => Path, + coerce => 1, + default => 'var/tmp/http', +); + +has ua => ( + is => 'ro', + default => sub { + LWP::UserAgent->new( agent => 'metacpan-api/1.0', ); + }, +); + +sub COMPONENT { + my $self = shift; + my ( $app, $config ) = @_; + my $app_config = $app->config; + + $config = $self->merge_config_hashes( + { + ( $app_config->{cpan} ? ( cpan => $app_config->{cpan} ) : () ), + ( + $app_config->{base_dir} + ? ( base_dir => $app_config->{base_dir} ) + : () + ), + ( + $app_config->{remote_cpan} + ? ( remote_cpan => $app_config->{remote_cpan} ) + : () + ), + }, + $config, + ); + return $self->SUPER::COMPONENT( $app, $config ); +} + +sub ACCEPT_CONTEXT { + my ( $self, $c ) = @_; + if ( !$self->es_query ) { + $self->_set_es_query( $c->model('ESQuery') ); + } + return $self; +} + +sub path { + my ( $self, $pauseid, $distvname, @file ) = @_; + my $base = $self->base_dir; + my $source_base = Path::Tiny::path( $base, $pauseid, $distvname ); + my $source = $source_base->child( $distvname, @file ); + return $source + if -e $source; + + # if the directory exists, we already extracted the archive, so if the + # file didn't exist, we can stop here + return undef + if -e $source_base->child($distvname); + + my $release_data + = $self->es_query->release->by_author_and_name( $pauseid, $distvname ) + ->{release} + or return undef; + + my $author_path = MetaCPAN::Util::author_dir($pauseid); + + my $http_author_dir + = $self->http_cache_dir->child( 'authors', $author_path ); + + my $local_cpan = $self->cpan; + my $cpan_author_dir + = $local_cpan && $local_cpan->child( 'authors', $author_path ); + + my $archive = $release_data->{archive}; + my ($local_archive) + = grep -e, + map $_->child($archive), + grep defined, + ( $cpan_author_dir, $http_author_dir ); + + if ( !$local_archive ) { + $local_archive = $http_author_dir->child($archive); + $self->fetch_from_cpan( $release_data->{download_url}, + $local_archive ) + or return undef; + } + my $extracted + = $self->extract_in( $local_archive, $source_base, $distvname ); + + return undef + if !-e $source; + + return $source; +} + +sub extract_in { + my ( $self, $archive_file, $base, $child_name ) = @_; + + my $archive = Archive::Any->new($archive_file); + + return undef + if $archive->is_naughty; + + my $extract_root = $base; + my $extract_dir = $base->child($child_name); + + if ( $archive->is_impolite ) { + $extract_root = $extract_dir; + } + + $extract_root->mkpath; + $archive->extract($extract_root); + + my @children = $extract_root->children; + if ( @children == 1 && -d $children[0] ) { + + # one directory, but with wrong name + if ( $children[0]->basename ne $child_name ) { + $children[0]->move($extract_dir); + } + } + else { + my $temp = Path::Tiny->tempdir( + TEMPLATE => 'cpan-extract-XXXXXXX', + TMPDIR => 0, + DIR => $extract_root, + CLEANUP => 0, + ); + + for my $child (@children) { + $child->move($temp); + } + + $temp->move($extract_dir); + } + + return $extract_dir; +} + +sub fetch_from_cpan { + my ( $self, $download_url, $local_archive ) = @_; + $local_archive->parent->mkpath; + + if ( my $remote_cpan = $self->remote_cpan ) { + $remote_cpan =~ s{/\z}{}; + $download_url + =~ s{\Ahttps?://(?:(?:backpan|cpan)\.metacpan\.org|(?:backpan\.|www\.)?cpan\.org|backpan\.cpantesters\.org)/}{$remote_cpan/}; + } + + my $ua = $self->ua; + my $response = $ua->mirror( $download_url, $local_archive ); + return $response->is_success; +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/lib/MetaCPAN/Server/QuerySanitizer.pm b/lib/MetaCPAN/Server/QuerySanitizer.pm new file mode 100644 index 000000000..bacee3a6c --- /dev/null +++ b/lib/MetaCPAN/Server/QuerySanitizer.pm @@ -0,0 +1,60 @@ +package MetaCPAN::Server::QuerySanitizer; + +use strict; +use warnings; + +use Moose; +use MetaCPAN::Types::TypeTiny qw( HashRef Maybe ); + +has query => ( + is => 'ro', + isa => Maybe [HashRef], + trigger => \&_build_clean_query, +); + +sub _build_clean_query { + my ($self) = @_; + my $search = $self->query + or return; + + _scan_hash_tree($search); + + return $search; +} + +# if we want a regexp we could do { $key = qr/^\Q$key\E$/ if !ref $key; } +my $key = 'script'; + +sub _scan_hash_tree { + my ($struct) = @_; + + my $ref = ref($struct); + if ( $ref eq 'HASH' ) { + while ( my ( $k, $v ) = each %$struct ) { + if ( $k eq $key ) { + MetaCPAN::Server::QuerySanitizer::Error->throw( + message => qq[Parameter "$key" not allowed], ); + } + _scan_hash_tree($v) if ref $v; + } + } + elsif ( $ref eq 'ARRAY' ) { + foreach my $item (@$struct) { + _scan_hash_tree($item) if ref($item); + } + } + + # Mickey: what about $ref eq 'JSON::PP::Boolean' ? +} + +__PACKAGE__->meta->make_immutable; + +{ + + package MetaCPAN::Server::QuerySanitizer::Error; + use Moose; + extends 'Throwable::Error'; + __PACKAGE__->meta->make_immutable; +} + +1; diff --git a/lib/MetaCPAN/Server/Role/JSONP.pm b/lib/MetaCPAN/Server/Role/JSONP.pm new file mode 100644 index 000000000..678318805 --- /dev/null +++ b/lib/MetaCPAN/Server/Role/JSONP.pm @@ -0,0 +1,13 @@ +package MetaCPAN::Server::Role::JSONP; + +use strict; +use warnings; + +use Moose::Role; + +has enable_jsonp => ( + is => 'ro', + default => 1, +); + +1; diff --git a/lib/MetaCPAN/Server/Role/Request.pm b/lib/MetaCPAN/Server/Role/Request.pm new file mode 100644 index 000000000..943291265 --- /dev/null +++ b/lib/MetaCPAN/Server/Role/Request.pm @@ -0,0 +1,23 @@ +package MetaCPAN::Server::Role::Request; + +use strict; +use warnings; + +use Moose::Role; + +around [qw(content_type header)] => sub { + my ( $orig, $self ) = ( shift, shift ); + my $header = $self->$orig(@_); + return unless ($header); + return $header =~ /^application\/x-www-form-urlencoded/ + ? 'application/json' + : $header; +}; + +sub fields { + my $self = shift; + my @fields = map { split /,/ } $self->param('fields'); + return @fields ? \@fields : undef; +} + +1; diff --git a/lib/MetaCPAN/Server/User.pm b/lib/MetaCPAN/Server/User.pm new file mode 100644 index 000000000..c84281957 --- /dev/null +++ b/lib/MetaCPAN/Server/User.pm @@ -0,0 +1,48 @@ +package MetaCPAN::Server::User; + +use strict; +use warnings; + +use Moose; + +extends 'Catalyst::Authentication::User'; + +has obj => ( + is => 'ro', + isa => 'MetaCPAN::Model::User::Account', + writer => '_set_obj', +); + +sub get_object { shift->obj } + +sub store {'Catalyst::Authentication::Plugin::Store::Proxy'} + +sub for_session { + shift->obj->id; +} + +sub from_session { + my ( $self, $c, $id ) = @_; + my $user = $c->model('ESModel')->doc('account')->get($id); + $self->_set_obj($user) if ($user); + return $user ? $self : undef; +} + +sub find_user { + my ( $self, $auth ) = @_; + $self->_set_obj( $auth->{user} ); + return $self; +} + +sub supports { + my ( $self, @feature ) = @_; + return 1 if ( grep { $_ eq 'session' } @feature ); +} + +sub data { + my $self = shift; + return $self->obj->meta->get_data( $self->obj ); +} + +__PACKAGE__->meta->make_immutable( inline_constructor => 0 ); +1; diff --git a/lib/MetaCPAN/Server/View/JSON.pm b/lib/MetaCPAN/Server/View/JSON.pm new file mode 100644 index 000000000..c43cb19d9 --- /dev/null +++ b/lib/MetaCPAN/Server/View/JSON.pm @@ -0,0 +1,20 @@ +package MetaCPAN::Server::View::JSON; + +use strict; +use warnings; + +use Cpanel::JSON::XS (); +use Moose; + +extends 'Catalyst::View::JSON'; + +sub encode_json { + my ( $self, $c, $data ) = @_; + my $encoder + = $c->req->looks_like_browser + ? Cpanel::JSON::XS->new->utf8->allow_blessed->pretty + : Cpanel::JSON::XS->new->utf8->allow_blessed; + $encoder->encode( exists $data->{rest} ? $data->{rest} : $data ); +} + +1; diff --git a/lib/MetaCPAN/Server/View/JSONP.pm b/lib/MetaCPAN/Server/View/JSONP.pm new file mode 100644 index 000000000..ee9fdf6a9 --- /dev/null +++ b/lib/MetaCPAN/Server/View/JSONP.pm @@ -0,0 +1,30 @@ +package MetaCPAN::Server::View::JSONP; + +use strict; +use warnings; + +use Cpanel::JSON::XS (); +use Encode qw( decode_utf8 ); +use Moose; + +extends 'Catalyst::View'; + +sub process { + my ( $self, $c ) = @_; + return 1 unless ( my $cb = $c->req->params->{callback} ); + my $body = $c->res->body; + if ( ref($body) ) { + local ($/); + $body = <$body>; + } + $body = decode_utf8($body); + my $content_type = $c->res->content_type; + return 1 if ( $content_type eq 'text/javascript' ); + if ( $content_type ne 'application/json' ) { + $body = Cpanel::JSON::XS->new->allow_nonref->ascii->encode($body); + } + $c->res->body("/**/$cb($body);"); + return 1; +} + +1; diff --git a/lib/MetaCPAN/Server/View/Pod.pm b/lib/MetaCPAN/Server/View/Pod.pm new file mode 100644 index 000000000..f1602cbb3 --- /dev/null +++ b/lib/MetaCPAN/Server/View/Pod.pm @@ -0,0 +1,56 @@ +package MetaCPAN::Server::View::Pod; + +use strict; +use warnings; + +use MetaCPAN::Pod::Renderer (); +use Moose; + +extends 'Catalyst::View'; + +sub process { + my ( $self, $c ) = @_; + + my $content = $c->res->has_body ? $c->res->body : $c->stash->{source}; + my $link_mappings = $c->stash->{link_mappings}; + my $url_prefix = $c->stash->{url_prefix}; + if ( ref $content ) { + $content = do { local $/; <$content> }; + } + + my ( $body, $content_type ); + my $accept = eval { $c->req->preferred_content_type } || 'text/html'; + my $show_errors = $c->stash->{show_errors}; + + my $renderer = $self->_factory( + ( $url_prefix ? ( perldoc_url_prefix => $url_prefix ) : () ), + no_errata_section => !$show_errors, + ( $link_mappings ? ( link_mappings => $link_mappings ) : () ), + ); + if ( $accept eq 'text/plain' ) { + $body = $renderer->to_text($content); + $content_type = 'text/plain'; + } + elsif ( $accept eq 'text/x-pod' ) { + $body = $renderer->to_pod($content); + $content_type = 'text/plain'; + } + elsif ( $accept eq 'text/x-markdown' ) { + $body = $renderer->to_markdown($content); + $content_type = 'text/plain'; + } + else { + $body = $renderer->to_html($content); + $content_type = 'text/html'; + } + + $c->res->content_type($content_type); + $c->res->body($body); +} + +sub _factory { + my $self = shift; + return MetaCPAN::Pod::Renderer->new(@_); +} + +1; diff --git a/lib/MetaCPAN/Types.pm b/lib/MetaCPAN/Types.pm new file mode 100644 index 000000000..8e60562b6 --- /dev/null +++ b/lib/MetaCPAN/Types.pm @@ -0,0 +1,12 @@ +package MetaCPAN::Types; + +use strict; +use warnings; + +use parent 'MooseX::Types::Combine'; + +__PACKAGE__->provide_types_from( qw( + MetaCPAN::Types::Internal +) ); + +1; diff --git a/lib/MetaCPAN/Types/Internal.pm b/lib/MetaCPAN/Types/Internal.pm new file mode 100644 index 000000000..b991f8fe3 --- /dev/null +++ b/lib/MetaCPAN/Types/Internal.pm @@ -0,0 +1,98 @@ +package MetaCPAN::Types::Internal; + +use strict; +use warnings; + +use ElasticSearchX::Model::Document::Mapping (); +use ElasticSearchX::Model::Document::Types qw( Type ); +use MetaCPAN::Util qw( is_bool true false ); +use MooseX::Getopt::OptionTypeMap (); +use MooseX::Types::Moose qw( ArrayRef Bool HashRef Item ); + +use MooseX::Types -declare => [ qw( + ESBool + Module + Identity + Dependency + Profile +) ]; + +subtype Module, as ArrayRef [ Type ['MetaCPAN::Document::Module'] ]; +coerce Module, from ArrayRef, via { + require MetaCPAN::Document::Module; + [ map { ref $_ eq 'HASH' ? MetaCPAN::Document::Module->new($_) : $_ } + @$_ ]; +}; +coerce Module, from HashRef, via { + require MetaCPAN::Document::Module; ## no perlimports + [ MetaCPAN::Document::Module->new($_) ]; +}; + +subtype Identity, as ArrayRef [ Type ['MetaCPAN::Model::User::Identity'] ]; +coerce Identity, from ArrayRef, via { + require MetaCPAN::Model::User::Identity; + [ + map { + ref $_ eq 'HASH' + ? MetaCPAN::Model::User::Identity->new($_) + : $_ + } @$_ + ]; +}; +coerce Identity, from HashRef, via { + require MetaCPAN::Model::User::Identity; ## no perlimports + [ MetaCPAN::Model::User::Identity->new($_) ]; +}; + +subtype Dependency, as ArrayRef [ Type ['MetaCPAN::Document::Dependency'] ]; +coerce Dependency, from ArrayRef, via { + require MetaCPAN::Document::Dependency; + [ + map { + ref $_ eq 'HASH' + ? MetaCPAN::Document::Dependency->new($_) + : $_ + } @$_ + ]; +}; +coerce Dependency, from HashRef, via { + require MetaCPAN::Document::Dependency; ## no perlimports + [ MetaCPAN::Document::Dependency->new($_) ]; +}; + +subtype Profile, as ArrayRef [ Type ['MetaCPAN::Document::Author::Profile'] ]; +coerce Profile, from ArrayRef, via { + require MetaCPAN::Document::Author::Profile; + [ + map { + ref $_ eq 'HASH' + ? MetaCPAN::Document::Author::Profile->new($_) + : $_ + } @$_ + ]; +}; +coerce Profile, from HashRef, via { + ## no perlimports + require MetaCPAN::Document::Author::Profile; + ## use perlimports + [ MetaCPAN::Document::Author::Profile->new($_) ]; +}; + +MooseX::Getopt::OptionTypeMap->add_option_type_to_map( + 'MooseX::Types::ElasticSearch::ES' => '=s' ); + +subtype ESBool, as Item, where { is_bool($_) }; +coerce ESBool, from Bool, via { + $_ ? true : false +}; + +$ElasticSearchX::Model::Document::Mapping::MAPPING{ESBool} + = $ElasticSearchX::Model::Document::Mapping::MAPPING{Bool}; + +use MooseX::Attribute::Deflator; +deflate 'ScalarRef', via {$$_}; +inflate 'ScalarRef', via { \$_ }; + +no MooseX::Attribute::Deflator; + +1; diff --git a/lib/MetaCPAN/Types/TypeTiny.pm b/lib/MetaCPAN/Types/TypeTiny.pm new file mode 100644 index 000000000..bf93d4ca2 --- /dev/null +++ b/lib/MetaCPAN/Types/TypeTiny.pm @@ -0,0 +1,161 @@ +package MetaCPAN::Types::TypeTiny; + +use strict; +use warnings; + +use Search::Elasticsearch (); +use Type::Library -base, -declare => ( qw( + ArrayRefPromote + + PerlMongers + Blog + Stat + Tests + RTIssueStatus + GitHubIssueStatus + BugSummary + RiverSummary + Resources + + Logger + HashRefCPANMeta + + CommaSepOption + + ES +) ); +use Type::Utils qw( as coerce declare extends from via ); + +BEGIN { + extends qw( + Types::Standard Types::Path::Tiny Types::URI Types::Common::String + ); +} + +declare ArrayRefPromote, as ArrayRef; +coerce ArrayRefPromote, from Value, via { [$_] }; + +declare PerlMongers, + as ArrayRef [ Dict [ url => Optional [Str], name => NonEmptySimpleStr ] ]; +coerce PerlMongers, from HashRef, via { [$_] }; + +declare Blog, + as ArrayRef [ Dict [ url => NonEmptySimpleStr, feed => Optional [Str] ] ]; +coerce Blog, from HashRef, via { [$_] }; + +declare Stat, + as Dict [ + mode => Int, + size => Int, + mtime => Int + ]; + +declare Tests, + as Dict [ fail => Int, na => Int, pass => Int, unknown => Int ]; + +declare RTIssueStatus, + as Dict [ + ( + map { $_ => Optional [Int] } + qw( active closed new open patched rejected resolved stalled ) + ), + source => Str + ]; + +declare GitHubIssueStatus, + as Dict [ + ( map { $_ => Optional [Int] } qw( active closed open ) ), + source => Str, + ]; + +declare BugSummary, + as Dict [ + rt => Optional [RTIssueStatus], + github => Optional [GitHubIssueStatus], + ]; + +declare RiverSummary, + as Dict [ ( map { $_ => Optional [Int] } qw(total immediate bucket) ), ]; + +declare Resources, + as Dict [ + license => Optional [ ArrayRef [Str] ], + homepage => Optional [Str], + bugtracker => + Optional [ Dict [ web => Optional [Str], mailto => Optional [Str] ] ], + repository => Optional [ + Dict [ + url => Optional [Str], + web => Optional [Str], + type => Optional [Str] + ] + ] + ]; +coerce Resources, from HashRef, via { + my $r = $_; + my $resources = {}; + for my $field (qw(license homepage bugtracker repository)) { + my $val = $r->{$field}; + if ( !defined $val ) { + next; + } + elsif ( !ref $val ) { + } + elsif ( ref $val eq 'HASH' ) { + $val = {%$val}; + delete @{$val}{ grep /^x_/, keys %$val }; + } + $resources->{$field} = $val; + } + return $resources; +}; + +declare Logger, as InstanceOf ['Log::Log4perl::Logger']; +coerce Logger, from ArrayRef, via { + return MetaCPAN::Role::Logger::_build_logger($_); +}; +coerce Logger, from HashRef, via { + return MetaCPAN::Role::Logger::_build_logger( [$_] ); +}; + +declare HashRefCPANMeta, as HashRef; +coerce HashRefCPANMeta, from InstanceOf ['CPAN::Meta'], via { + my $struct = eval { $_->as_struct( { version => 2 } ); }; + return $struct ? $struct : $_->as_struct; +}; + +declare CommaSepOption, as ArrayRef [ StrMatch [qr{^[^, ]+$}] ]; +coerce CommaSepOption, from ArrayRef [Str], via { + return [ map split(/\s*,\s*/), @$_ ]; +}; +coerce CommaSepOption, from Str, via { + return [ map split(/\s*,\s*/), $_ ]; +}; + +declare ES, as Object; +coerce ES, from Str, via { + my $server = $_; + $server = "127.0.0.1$server" if ( $server =~ /^:/ ); + return Search::Elasticsearch->new( + nodes => $server, + cxn => 'HTTPTiny', + ); +}; + +coerce ES, from HashRef, via { + return Search::Elasticsearch->new( { + cxn => 'HTTPTiny', + %$_, + } ); +}; + +coerce ES, from ArrayRef, via { + my @servers = @$_; + @servers = map { /^:/ ? "127.0.0.1$_" : $_ } @servers; + return Search::Elasticsearch->new( + nodes => \@servers, + cxn => 'HTTPTiny', + ); +}; + +1; diff --git a/lib/MetaCPAN/Util.pm b/lib/MetaCPAN/Util.pm new file mode 100644 index 000000000..582af0637 --- /dev/null +++ b/lib/MetaCPAN/Util.pm @@ -0,0 +1,311 @@ +package MetaCPAN::Util; + +# ABSTRACT: Helper functions for MetaCPAN + +use strict; +use warnings; +use version; + +use Cpanel::JSON::XS (); ## no perlimports +use Cwd (); +use Digest::SHA qw( sha1_base64 sha1_hex ); +use Encode qw( decode_utf8 ); +use File::Basename (); +use File::Spec (); +use Ref::Util qw( + is_arrayref + is_hashref + is_plain_arrayref + is_plain_hashref + is_ref +); +use Sub::Exporter -setup => { + exports => [ qw( + root_dir + author_dir + diff_struct + digest + extract_section + fix_pod + fix_version + generate_sid + hit_total + numify_version + pod_lines + strip_pod + single_valued_arrayref_to_scalar + true + false + is_bool + to_bool + MAX_RESULT_WINDOW + ) ] +}; + +# Limit the maximum result window to 1000, really should be enough! +use constant MAX_RESULT_WINDOW => 1000; + +sub true (); +*true = \&Cpanel::JSON::XS::true; +sub false (); +*false = \&Cpanel::JSON::XS::false; +sub is_bool ($); +*is_bool = \&Cpanel::JSON::XS::is_bool; +sub to_bool ($) { $_[0] ? true : false } + +sub root_dir { + Cwd::abs_path( File::Spec->catdir( + File::Basename::dirname(__FILE__), + ( File::Spec->updir ) x 2 + ) ); +} + +sub digest { + my $digest = sha1_base64( join( "\0", grep {defined} @_ ) ); + $digest =~ tr{+/}{-_}; + return $digest; +} + +sub generate_sid { + return sha1_hex( rand . $$ . {} . time ); +} + +sub numify_version { + my $version = shift || return 0; + $version = fix_version($version); + $version =~ s/_//g; + if ( $version =~ s/^v//i || $version =~ tr/.// > 1 ) { + my @parts = split /\./, $version; + my $n = shift @parts; + return 0 unless defined $n; + $version + = sprintf( join( '.', '%s', ( '%03s' x @parts ) ), $n, @parts ); + } + $version += 0; + return $version; +} + +sub fix_version { + my $version = shift; + return 0 unless defined $version; + my $v = ( $version =~ s/^v//i ); + $version =~ s/[^\d\._].*//; + $version =~ s/\.[._]+/./; + $version =~ s/[._]*_[._]*/_/g; + $version =~ s/\.{2,}/./g; + $v ||= $version =~ tr/.// > 1; + $version ||= 0; + return ( ( $v ? 'v' : '' ) . $version ); +} + +sub author_dir { + my $pauseid = shift; + return sprintf( 'id/%1$.1s/%1$.2s/%1$s', $pauseid ); +} + +sub hit_total { + my $res = shift; + my $total = $res && $res->{hits} && $res->{hits}{total}; + if ( ref $total ) { + return $total->{value}; + } + return $total; +} + +# TODO: E +sub strip_pod { + my $pod = shift; + $pod =~ s/L<([^\/]*?)\/([^\/]*?)>/$2 in $1/g; + $pod =~ s/\w<(.*?)(\|.*?)?>/$1/g; + return $pod; +} + +sub extract_section { + my ( $pod, $section ) = @_; + eval { $pod = decode_utf8( $pod, Encode::FB_CROAK ) }; + return undef + unless ( $pod =~ /^=head1\s+$section\b(.*?)(^((\=head1)|(\=cut)))/msi + || $pod =~ /^=head1\s+$section\b(.*)/msi ); + my $out = $1; + $out =~ s/^\s*//g; + $out =~ s/\s*$//g; + return $out; +} + +sub pod_lines { + my $content = shift; + return [] unless ($content); + my @lines = split( "\n", $content ); + my @return; + my $length = 0; + my $start = 0; + my $slop = 0; + + # Use c-style for loop to avoid copying all the strings. + my $num_lines = scalar @lines; + for ( my $i = 0; $i < $num_lines; ++$i ) { + my $line = $lines[$i]; + + if ( $line =~ /\A=cut/ ) { + $length++; + $slop++; + push( @return, [ $start - 1, $length ] ) + if ( $start && $length ); + $start = $length = 0; + } + + # Match lines that actually look like valid pod: "=pod\n" or "=pod x\n". + elsif ( $line =~ /^=[a-zA-Z][a-zA-Z0-9]*(?:\s+|$)/ && !$length ) { + + # Re-use iterator as line number. + $start = $i + 1; + } + + if ($start) { + $length++; + $slop++ if ( $line =~ /\S/ ); + } + } + + push @return, [ $start - 1, $length ] + if ( $start && $length ); + + return \@return, $slop; +} + +sub single_valued_arrayref_to_scalar { + my ( $array, $fields ) = @_; + my $is_arrayref = is_arrayref($array); + + $array = [$array] unless $is_arrayref; + + my $has_fields = defined $fields ? 1 : 0; + $fields ||= []; + my %fields_to_extract = map { $_ => 1 } @{$fields}; + foreach my $hash ( @{$array} ) { + next unless is_hashref($hash); + foreach my $field ( %{$hash} ) { + next if ( $has_fields and not $fields_to_extract{$field} ); + my $value = $hash->{$field}; + + # We only operate when have an ArrayRef of one value + next unless is_arrayref($value) && scalar @{$value} == 1; + $hash->{$field} = $value->[0]; + } + } + return $is_arrayref ? $array : @{$array}; +} + +sub diff_struct { + my ( $old_root, $new_root, $allow_extra ) = @_; + my (@queue) = [ $old_root, $new_root, '', $allow_extra ]; + + while ( my $check = shift @queue ) { + my ( $old, $new, $path, $allow_extra ) = @$check; + if ( !defined $new ) { + return [ $path, $old, $new ] + if defined $old; + } + elsif ( !is_ref($new) ) { + return [ $path, $old, $new ] + if !defined $old + or is_ref($old) + or $new ne $old; + } + elsif ( is_plain_arrayref($new) ) { + return [ $path, $old, $new ] + if !is_plain_arrayref($old) || @$new != @$old; + push @queue, map [ $old->[$_], $new->[$_], "$path/$_" ], + 0 .. $#$new; + } + elsif ( is_plain_hashref($new) ) { + return [ $path, $old, $new ] + if !is_plain_hashref($old) + || !$allow_extra && keys %$new != keys %$old; + push @queue, map [ $old->{$_}, $new->{$_}, "$path/$_" ], + keys %$new; + } + elsif ( is_bool($new) ) { + return [ $path, $old, $new ] + if !is_bool($old) || $old != $new; + } + else { + die "can't compare $new type data at $path"; + } + } + return undef; +} + +1; + +__END__ + +=head1 FUNCTIONS + +=head2 digest + +This function will digest the passed parameters to a 32 byte string and makes it url safe. +It consists of the characters A-Z, a-z, 0-9, - and _. + +The digest is built using L. + +=head2 single_valued_arrayref_to_scalar + +Elasticsearch 1.x changed the data structure returned when fields are used. +For example before one could get a ArrayRef[HashRef[Str]] where now +that will come in the form of ArrayRef[HashRef[ArrayRef[Str]]] + +This function reverses that behavior +By default it will do that for all fields that are a single valued array, +but one may pass in a list of fields to restrict this behavior only to the +fields given. + +So this: + + $self->single_valued_arrayref_to_scalar( + [ + { + name => ['WhizzBang'], + provides => ['Food', 'Bar'], + }, + ... + ]); + +yields: + + [ + { + name => 'WhizzBang', + provides => ['Food', 'Bar'], + }, + ... + ] + +and this estrictive example): + + $self->single_valued_arrayref_to_scalar( + [ + { + name => ['WhizzBang'], + provides => ['Food'], + }, + ... + ], ['name']); + +yields: + + [ + { + name => 'WhizzBang', + provides => ['Food'], + }, + ... + ] + +=head2 diff_struct + + my $changed = diff_struct($old_hashref, $new_hashref); + +Accepts two data structures and returns a true value if they are different. + +=cut diff --git a/lib/Plack/Middleware/CPANSource.pm b/lib/Plack/Middleware/CPANSource.pm deleted file mode 100644 index 027d77641..000000000 --- a/lib/Plack/Middleware/CPANSource.pm +++ /dev/null @@ -1,65 +0,0 @@ -package Plack::Middleware::CPANSource; - -use parent qw( Plack::Middleware ); - -use Archive::Tar::Wrapper; -use File::Copy; -use File::Path qw(make_path); -use Modern::Perl; -use Path::Class qw(file); - -sub call { - my ( $self, $env ) = @_; - - if ( $env->{REQUEST_URI} =~ m{\A/source/(\w*)/([^\/\?]*)/([^\?]*)} ) { - my $new_path = $self->file_path( $1, $2, $3 ); - $env->{PATH_INFO} = $new_path if $new_path; - } - - return $self->app->( $env ); -} - -sub file_path { - - my ( $self, $pauseid, $distvname, $file ) = @_; - - my $author_folder = sprintf( "%s/%s/%s/%s", - substr( $pauseid, 0, 1 ), - substr( $pauseid, 0, 2 ), - $pauseid, $distvname ); - my $base_folder = '/home/olaf/cpan-source/'; - - my $rewrite_path = "$author_folder/$file"; - my $dest_file = $base_folder . $rewrite_path; - - return $rewrite_path if ( -e $dest_file ); - - my $cpan_path = "/home/cpan/CPAN/authors/id/$author_folder.tar.gz"; - return if ( !-e $cpan_path ); - - my $arch = Archive::Tar::Wrapper->new(); - my $logic_path = "$distvname/$file"; # path within unzipped archive - - $arch->read( $cpan_path, $logic_path ); # read only one file - my $phys_path = $arch->locate( $logic_path ); - - if ( $phys_path ) { - make_path( file( $dest_file )->dir, {} ); - copy( $phys_path, $dest_file ); - return $rewrite_path; - } - - return; - -} - -1; - -=pod - -=head2 file_path( $pauseid, $distvname, $file ) - - print $self->file_path( 'Plack-Middleware-HTMLify-0.1.1', 'I/IO/IONCACHE/Plack-Middleware-HTMLify-0.1.1.tar.gz', 'lib/Plack/Middleware/HTMLify.pm' ); - # id/I/IO/IONCACHE/Plack-Middleware-HTMLify-0.1.1/lib/Plack/Middleware/HTMLify.pm - -=cut diff --git a/log4perl.conf b/log4perl.conf new file mode 100644 index 000000000..a6f90c4ef --- /dev/null +++ b/log4perl.conf @@ -0,0 +1,7 @@ +log4perl.rootLogger=DEBUG, OUTPUT + +log4perl.appender.OUTPUT=Log::Log4perl::Appender::Screen +log4perl.appender.OUTPUT.stderr=1 + +log4perl.appender.OUTPUT.layout=PatternLayout +log4perl.appender.OUTPUT.layout.ConversionPattern=[%d] [%p] [%X{url}] %m%n diff --git a/log4perl_prod.conf b/log4perl_prod.conf new file mode 100644 index 000000000..1adc5c024 --- /dev/null +++ b/log4perl_prod.conf @@ -0,0 +1,18 @@ +log4perl.rootLogger=WARN, OUTPUT, SYSLOG + +log4perl.appender.OUTPUT=Log::Log4perl::Appender::Screen +log4perl.appender.OUTPUT.stderr=1 + +log4perl.appender.OUTPUT.layout=PatternLayout +log4perl.appender.OUTPUT.layout.ConversionPattern=[%d] [%p] [%X{url}] %m%n + +log4perl.appender.SYSLOG=Log::Dispatch::Syslog +log4perl.appender.SYSLOG.ident = metacpan_api +log4perl.appender.SYSLOG.facility = local0 +log4perl.appender.SYSLOG.layout = Log::Log4perl::Layout::JSON +log4perl.appender.SYSLOG.layout.field.message = %m{chomp} +log4perl.appender.SYSLOG.layout.field.category = %c +log4perl.appender.SYSLOG.layout.field.class = %C +log4perl.appender.SYSLOG.layout.field.file = %F{1} +log4perl.appender.SYSLOG.layout.field.sub = %M{1} +log4perl.appender.SYSLOG.layout.include_mdc = 1 diff --git a/metacpan_server.yaml b/metacpan_server.yaml new file mode 100644 index 000000000..b4ee70242 --- /dev/null +++ b/metacpan_server.yaml @@ -0,0 +1,36 @@ +--- +git: /usr/bin/git + +cpan: /CPAN +remote_cpan: https://cpan.metacpan.org/ +secret: "the stone roses" +level: info +elasticsearch_servers: + client: '2_0::Direct' + nodes: http://elasticsearch:9200 +minion_dsn: "postgresql://metacpan:t00lchain@pghost:5432/minion_queue" +port: 5000 + +logger: + class: Log::Log4perl::Appender::File + filename: ../var/log/metacpan.log + syswrite: 1 + +smtp: + host: smtp.fastmail.com + port: 465 + username: foo@metacpan.org + password: seekrit + +oauth: + github: + key: seekrit + secret: seekrit + google: + key: seekrit + secret: seekrit + twitter: + key: seekrit + secret: seekrit + +front_end_url: http://0.0.0.0:5001 diff --git a/metacpan_server_testing.yaml b/metacpan_server_testing.yaml new file mode 100644 index 000000000..23b6e4406 --- /dev/null +++ b/metacpan_server_testing.yaml @@ -0,0 +1,38 @@ +git: /usr/bin/git +cpan: var/t/tmp/fakecpan +remote_cpan: file://__HOME__/var/t/tmp/fakecpan +die_on_error: 1 +level: warn +port: 5000 +source_base: var/t/tmp/source + +elasticsearch_servers: + client: '2_0::Direct' + nodes: ${ES:-http://elasticsearch_test:9200} + +minion_dsn: "postgresql://metacpan:t00lchain@pghost:5432/minion_queue" + +logger: + class: Log::Log4perl::Appender::Screen + name: testing + +secret: weak + +smtp: + host: smtp.fastmail.com + port: 465 + username: foo@metacpan.org + password: seekrit + +oauth: + github: + key: seekrit + secret: seekrit + google: + key: seekrit + secret: seekrit + twitter: + key: seekrit + secret: seekrit + +front_end_url: http://0.0.0.0:5001 diff --git a/perlimports.toml b/perlimports.toml new file mode 100644 index 000000000..f5ed6d190 --- /dev/null +++ b/perlimports.toml @@ -0,0 +1,25 @@ +# Valid log levels are: +# debug, info, notice, warning, error, critical, alert, emergency +# critical, alert and emergency are not currently used. +# +# Please use boolean values in this config file. Negated options (--no-*) are +# not permitted here. Explicitly set options to true or false. +# +# Some of these values deviate from the regular perlimports defaults. In +# particular, you're encouraged to leave preserve_duplicates and +# preserve_unused disabled. + +cache = false # setting this to true is currently discouraged +ignore_modules = ["Catalyst::Runtime","Module::Pluggable", "namespace::clean", "Test::More", "Type::Library", "With::Roles", "File::Find::Rule::Perl"] +ignore_modules_filename = "" +ignore_modules_pattern = "" # regex like "^(Foo|Foo::Bar)" +ignore_modules_pattern_filename = "" +libs = ["lib", "t/lib"] +log_filename = "" +log_level = "warn" +never_export_modules = [] +never_export_modules_filename = "" +padding = true +preserve_duplicates = false +preserve_unused = false +tidy_whitespace = true diff --git a/precious.toml b/precious.toml new file mode 100644 index 000000000..8fdcfe414 --- /dev/null +++ b/precious.toml @@ -0,0 +1,47 @@ +exclude = [ + "/.build/**", + "/blib/**", + "/root/assets/**", + "/local/**", + "/test-data/**", +] + +[commands.perlimports] +type = "both" +include = [ "**/*.{pl,pm,t,psgi}", "bin/metacpan" ] +cmd = [ "perlimports" ] +lint-flags = ["--lint" ] +tidy-flags = ["-i" ] +ok-exit-codes = 0 +expect-stderr = true + +[commands.perlcritic] +type = "lint" +include = [ "**/*.{pl,pm,t,psgi}", "bin/metacpan" ] +cmd = [ "perlcritic", "--profile=$PRECIOUS_ROOT/.perlcriticrc" ] +ok-exit-codes = 0 +lint-failure-exit-codes = 2 + +[commands.perltidy] +type = "both" +include = [ "**/*.{pl,pm,t,psgi}", "bin/metacpan" ] +cmd = [ "perltidy", "--profile=$PRECIOUS_ROOT/.perltidyrc" ] +lint-flags = [ "--assert-tidy", "--no-standard-output", "--outfile=/dev/null" ] +tidy-flags = [ "--backup-and-modify-in-place", "--backup-file-extension=/" ] +ok-exit-codes = 0 +lint-failure-exit-codes = 2 +ignore-stderr = "Begin Error Output Stream" +label = ["perltidy"] + +[commands.omegasort-gitignore] +type = "both" +include = "**/.gitignore" +cmd = [ "omegasort", "--sort", "path", "--unique" ] +lint-flags = "--check" +tidy-flags = "--in-place" +ok-exit-codes = 0 +lint-failure-exit-codes = 1 +ignore-stderr = [ + "The .+ file is not sorted", + "The .+ file is not unique", +] diff --git a/root/static/favicon.ico b/root/static/favicon.ico new file mode 100644 index 000000000..23dd564a6 Binary files /dev/null and b/root/static/favicon.ico differ diff --git a/schema/CPAN-meta.sqlite b/schema/CPAN-meta.sqlite deleted file mode 100644 index 23a4b99a4..000000000 Binary files a/schema/CPAN-meta.sqlite and /dev/null differ diff --git a/source/app.psgi b/source/app.psgi deleted file mode 100644 index 4ed061c2c..000000000 --- a/source/app.psgi +++ /dev/null @@ -1,14 +0,0 @@ -use Plack::App::Directory; -use Plack::Builder; - -# plackup -I../lib - -my $app = Plack::App::Directory->new(root => "/home/olaf/cpan-source")->to_app; - -builder { - enable "Plack::Middleware::CPANSource"; - enable 'Header', - set => ['Content-Type' => 'text/plain']; - $app; -}; - diff --git a/t/00_setup.t b/t/00_setup.t new file mode 100644 index 000000000..8d88f8ba7 --- /dev/null +++ b/t/00_setup.t @@ -0,0 +1,123 @@ +use strict; +use warnings; +use lib 't/lib'; + +use CPAN::Faker 0.010 (); +use Devel::Confess; +use MetaCPAN::Script::Tickets (); +use MetaCPAN::TestHelpers qw( + fakecpan_configs_dir + fakecpan_dir + get_config + tmp_dir + write_find_ls +); +use MetaCPAN::TestServer (); +use Test::More 0.96; +use URI::FromHash qw( uri ); + +BEGIN { + # We test parsing bad YAML. This attempt emits a noisy warning which is not + # helpful in test output, so we'll suppress it here. + $SIG{__WARN__} = sub { + my $msg = shift; + return if $msg =~ m{found a duplicate key}; + warn $msg; + }; +} + +# Ensure we're starting fresh +my $tmp_dir = tmp_dir(); +$tmp_dir->remove_tree( { safe => 0 } ); +$tmp_dir->mkpath; + +ok( $tmp_dir->stat, "$tmp_dir exists for testing" ); + +my $server = MetaCPAN::TestServer->new; +$server->setup; + +my $config = get_config(); +$config->{es} = $server->es_client; + +my $mod_faker = 'Module::Faker::Dist::WithPerl'; +eval "require $mod_faker" or die $@; ## no critic (StringyEval) + +my $fakecpan_dir = fakecpan_dir(); +$fakecpan_dir->remove_tree; +$fakecpan_dir = fakecpan_dir(); # recreate dir + +my $fakecpan_configs = fakecpan_configs_dir(); + +my $cpan = CPAN::Faker->new( { + source => $fakecpan_configs->child('configs')->stringify, + dest => $fakecpan_dir->stringify, + dist_class => $mod_faker, +} ); + +ok( $cpan->make_cpan, 'make fake cpan' ); +$fakecpan_dir->child('authors')->mkpath; +$fakecpan_dir->child('indices')->mkpath; + +# make some changes to 06perms.txt +{ + my $perms_file = $fakecpan_dir->child('modules')->child('06perms.txt'); + my $perms = $perms_file->slurp; + $perms =~ s/^Some,LOCAL,f$/Some,MO,f/m; + my $fh = $perms_file->openw; + print $fh $perms; + + # Temporary hack. Remove after DarkPAN 06perms generation is fixed. + print $fh 'CPAN::Test::Dummy::Perl5::VersionBump,MIYAGAWA,f', "\n"; + print $fh 'CPAN::Test::Dummy::Perl5::VersionBump,OALDERS,c', "\n"; + + close $fh; +} + +# Help debug inconsistent parsing failures. +use Parse::PMFile (); +local $Parse::PMFile::VERBOSE = $ENV{TEST_VERBOSE} ? 9 : 0; + +my $src_dir = $fakecpan_configs; + +$src_dir->child('00whois.xml') + ->copy( $fakecpan_dir->child(qw(authors 00whois.xml)) ); + +$src_dir->child('author-1.0.json') + ->copy( $fakecpan_dir->child(qw(authors id M MO MO author-1.0.json)) ); + +$src_dir->child('bugs.tsv')->copy( $fakecpan_dir->child('bugs.tsv') ); + +$src_dir->child('mirrors.json') + ->copy( $fakecpan_dir->child(qw(indices mirrors.json)) ); + +$src_dir->child('08pumpkings.txt.gz') + ->copy( $fakecpan_dir->child(qw(authors 08pumpkings.txt.gz)) ); + +write_find_ls($fakecpan_dir); + +$server->index_permissions; +$server->index_packages; +$server->index_releases; +$server->set_latest; +$server->set_first; +$server->index_authors; +$server->prepare_user_test_data; +$server->index_cpantesters; +$server->index_mirrors; +$server->index_favorite; +$server->index_cover; + +ok( + MetaCPAN::Script::Tickets->new_with_options( { + %{$config}, + rt_summary_url => uri( + scheme => 'file', + path => $fakecpan_dir->child('bugs.tsv')->absolute->stringify, + ), + } )->run, + 'tickets' +); + +$server->wait_for_es(); + +done_testing; diff --git a/t/01_darkpan.t b/t/01_darkpan.t new file mode 100644 index 000000000..d34ddc85c --- /dev/null +++ b/t/01_darkpan.t @@ -0,0 +1,27 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Devel::Confess; +use MetaCPAN::DarkPAN (); +use MetaCPAN::Tests::Controller::Search::DownloadURL (); +use MetaCPAN::TestServer (); +use Test::More; +use Test::RequiresInternet ( 'cpan.metacpan.org' => 80 ); + +my $darkpan = MetaCPAN::DarkPAN->new; +my $server = MetaCPAN::TestServer->new( cpan_dir => $darkpan->base_dir ); + +# create DarkPAN +$darkpan->run; + +$server->index_releases( bulk_size => 1 ); + +SKIP: { + # XXX "path does not support inner_hits" + skip( 'Download URL not yet fully implemented', 1 ); + my $url_tests = MetaCPAN::Tests::Controller::Search::DownloadURL->new; + $url_tests->run; +} + +done_testing(); diff --git a/t/api/queue.t b/t/api/queue.t new file mode 100644 index 000000000..492244b38 --- /dev/null +++ b/t/api/queue.t @@ -0,0 +1,24 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Test::More skip_all => 'disabling Minion tests to avoid needing postgres'; +use MetaCPAN::DarkPAN (); +use Path::Tiny qw( path ); +use Test::Mojo; + +my $t = Test::Mojo->new('MetaCPAN::API'); +my $app = $t->app; + +ok( $app, 'queue app' ); +isa_ok $app, 'MetaCPAN::API'; + +my $darkpan = MetaCPAN::DarkPAN->new->base_dir; +my $release = path( $darkpan, 'authors/id/E/ET/ETHER/Try-Tiny-0.23.tar.gz' ); + +$app->minion->enqueue( index_release => [$release] ); +$app->minion->enqueue( index_release => [ '--latest', $release ] ); + +$app->minion->perform_jobs; + +done_testing(); diff --git a/t/config.t b/t/config.t new file mode 100644 index 000000000..cf09877d1 --- /dev/null +++ b/t/config.t @@ -0,0 +1,12 @@ +#!perl + +use strict; +use warnings; + +use MetaCPAN::Server::Config (); +use Test::More; + +my $config = MetaCPAN::Server::Config::config(); +ok($config); + +done_testing(); diff --git a/t/dist.t b/t/dist.t deleted file mode 100644 index 6c636c2b1..000000000 --- a/t/dist.t +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/perl - -use Data::Dump qw( dump ); -use Modern::Perl; -use Test::More qw( no_plan ); - -require_ok( 'MetaCPAN' ); -require_ok( 'MetaCPAN::Dist' ); - -my $cpan = MetaCPAN->new; - -my $dist = $cpan->dist( 'Moose' ); -isa_ok( $dist, 'MetaCPAN::Dist' ); - -ok ( $dist->module_rs->count, "got some modules" ); diff --git a/t/document/author.t b/t/document/author.t new file mode 100644 index 000000000..6db29b13c --- /dev/null +++ b/t/document/author.t @@ -0,0 +1,13 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Document::Author (); +use Test::More; + +my @errors = MetaCPAN::Document::Author->validate( + { perlmongers => { name => 'foo.pm' } } ); + +ok( !( grep { $_->{field} eq 'perlmongers' } @errors ), 'perlmongers ok' ); + +done_testing; diff --git a/t/document/file.t b/t/document/file.t new file mode 100644 index 000000000..0da986ebb --- /dev/null +++ b/t/document/file.t @@ -0,0 +1,594 @@ +use strict; +use warnings; +use lib 't/lib'; + +use CPAN::Meta (); +use MetaCPAN::Document::File (); +use MetaCPAN::Util qw(true false); +use Test::More; + +sub cpan_meta { + CPAN::Meta->new( { + name => 'who-cares', + version => 0, + } ); +} + +sub new_file_doc { + my %args = @_; + + my $mods = $args{module} || []; + $mods = [$mods] unless ref($mods) eq 'ARRAY'; + + my $pkg_template = <<'PKG'; +package %s; +our $VERSION = 1; +PKG + + my $name = $args{name} || 'SomeModule.pm'; + my $file = MetaCPAN::Document::File->new( + author => 'CPANER', + path => $name, + release => 'Some-Release-1', + distribution => 'Some-Release', + name => $name, + + # Passing in "content" will override + # but defaulting to package statements will help avoid buggy tests. + content => \( + join "\n", + ( map { sprintf $pkg_template, $_->{name} } @$mods ), + "\n\n=head1 NAME\n\n${name} - abstract\n\n=cut\n\n", + ), + + %args, + ); + $file->set_indexed( cpan_meta() ); + return $file; +} + +sub test_attributes { + my ( $obj, $att ) = @_; + local $Test::Builder::Level = $Test::Builder::Level + 1; + foreach my $key ( sort keys %$att ) { + my $got = $obj->$key; + if ( $key eq 'pod' ) { + + # Dereference scalar to compare strings. + $got = $$got; + } + is_deeply $got, $att->{$key}, $key; + } +} + +subtest 'helper' => sub { + my $file = new_file_doc( module => { name => 'Foo::Bar' }, ); + + is $file->module->[0]->indexed, true, 'Regular package name indexed'; +}; + +subtest 'basic' => sub { + my $content = <<'END'; +package Foo; +use strict; + +=head1 NAME +X X + +MyModule - mymodule1 abstract + + not this + +=pod + +bla + +=cut + +more perl code + +=head1 SYNOPSIS + +more pod +more + +even more + +END + + my $file = new_file_doc( content => \$content ); + + is( $file->abstract, 'mymodule1 abstract' ); + is( $file->documentation, 'MyModule' ); + is( $file->documentation_length, 8 ); + is_deeply( $file->pod_lines, [ [ 3, 12 ], [ 18, 6 ] ] ); + is( $file->sloc, 3 ); + is( $file->slop, 11 ); + + is( + ${ $file->pod }, + q[NAME MyModule - mymodule1 abstract not this bla SYNOPSIS more pod more even more], + 'pod text' + ); +}; + +subtest 'slop without pod_lines' => sub { + my $content = <<'END'; +package Foo; +use strict; + +=head1 NAME +X X + +MyModule - mymodule1 abstract + + not this + +=pod + +bla + +=cut + +more perl code + +=head1 SYNOPSIS + +more pod +more + +even more + +END + + my $file = new_file_doc( content => \$content ); + + is( $file->slop, 11, 'slop is correct without first calling pod_lines' ); + is_deeply( $file->pod_lines, [ [ 3, 12 ], [ 18, 6 ] ] ); +}; + +subtest 'just pod' => sub { + my $content = <<'END'; + +=head1 NAME + +MyModule + +END + + my $file = new_file_doc( content => \$content ); + + is( $file->abstract, undef ); + is( $file->documentation, 'MyModule' ); + test_attributes $file, + { + sloc => 0, + slop => 2, + pod_lines => [ [ 1, 3 ] ], + pod => q[NAME MyModule], + }; +}; + +subtest 'script' => sub { + my $content = <<'END'; +#!/bin/perl + +=head1 NAME + +Script - a command line tool + +=head1 VERSION + +Version 0.5.0 + +END + + my $file = new_file_doc( content => \$content ); + + is( $file->abstract, 'a command line tool' ); + is( $file->documentation, 'Script' ); + test_attributes $file, + { + sloc => 0, + slop => 4, + pod_lines => [ [ 2, 7 ] ], + pod => q[NAME Script - a command line tool VERSION Version 0.5.0], + }; +}; + +subtest 'test script' => sub { + my $content = <<'END'; +#$Id: Config.pm,v 1.5 2008/09/02 13:14:18 kawas Exp $ + +=head1 NAME + +=for html foobar + + MOBY::Config.pm - An object B information about how to get access to teh Moby databases, resources, etc. from the +mobycentral.config file + +=cut + + +=head2 USAGE + +=cut + +package MOBY::Config; + +END + + my $file = new_file_doc( + path => 't/bar/bat.t', + module => { name => 'MOBY::Config' }, + content => \$content, + ); + + is( $file->abstract, + 'An object containing information about how to get access to teh Moby databases, resources, etc. from the mobycentral.config file' + ); + is( $file->documentation, 'MOBY::Config' ); + is( $file->level, 2 ); + test_attributes $file, { + sloc => 1, + slop => 7, + pod_lines => [ [ 2, 8 ], [ 12, 3 ] ], + + # I don't know the original intent of the pod but here are my observations: + # * The `=for html` region has nothing in it. + # * Podchecker considers it erroneous to have verbatim in the NAME section. + pod => + q[NAME MOBY::Config.pm - An object B information about how to get access to teh Moby databases, resources, etc. from the mobycentral.config file USAGE], + }; +}; + +subtest 'Packages starting with underscore are not indexed' => sub { + my $file = new_file_doc( module => { name => '_Package::Foo' } ); + is( $file->module->[0]->indexed, false, 'Package is not indexed' ); +}; + +subtest 'files listed under other files' => sub { + my $content = <<'END'; +=head1 NAME + +Makefile.PL - configure Makefile + +=head1 DESCRIPTION + +just a makefile description + +END + my $file = new_file_doc( + name => 'Makefile.PL', + content => \$content, + ); + + is( $file->indexed, false, + 'File listed under other files is not indexed' ); +}; + +subtest 'pod name/package mismatch' => sub { + my $content = <<'END'; +package + Number::Phone::NANP::ASS; + +# numbering plan at http://www.itu.int/itudoc/itu-t/number/a/sam/86412.html + +use strict; + +use base 'Number::Phone::NANP'; + +use Number::Phone::Country qw(noexport); + +our $VERSION = 1.1; + +my $cache = {}; + +# NB this module doesn't register itself, the NANP module should be +# used and will load this one as necessary + +=head1 NAME + +Number::Phone::NANP::AS + +AS-specific methods for Number::Phone + +=cut + +1; +END + my $file = new_file_doc( content => \$content, ); + is( $file->sloc, 8, '8 lines of code' ); + is( $file->slop, 4, '4 lines of pod' ); + is( + $file->abstract, + 'AS-specific methods for Number::Phone', + 'abstract text' + ); + + # changed because the extracted document from content takes + # precedence over a non-indexed module. + # test may need an update if we want to see the name + # from the module. -- Mickey + is( $file->documentation, 'Number::Phone::NANP::AS', 'document text' ); + + is_deeply( $file->pod_lines, [ [ 18, 7 ] ], 'correct pod_lines' ); + + is( + ${ $file->pod }, + q[NAME Number::Phone::NANP::AS AS-specific methods for Number::Phone], + 'pod text' + ); +}; + +subtest 'hidden package' => sub { + my $content = <<'END'; +package # hide the package from PAUSE + Perl6Attribute; + +=head1 NAME + +C -- An example attribute metaclass for Perl 6 style attributes + +END + my $file = new_file_doc( + name => 'Perl6Attribute.pod', + module => [ { name => 'main', version => 1.1 } ], + content => \$content, + ); + is( $file->documentation, 'Perl6Attribute' ); + is( $file->abstract, + 'An example attribute metaclass for Perl 6 style attributes' ); + test_attributes $file, + { + sloc => 2, + slop => 2, + pod_lines => [ [ 3, 3 ], ], + pod => + q[NAME "Perl6Attribute" -- An example attribute metaclass for Perl 6 style attributes], + }; +}; + +subtest 'pod after __DATA__' => sub { + + my $content = <<'END'; +package Foo; + +__DATA__ + +some data + +=head1 NAME + +Foo -- An example attribute metaclass for Perl 6 style attributes + +=head1 DESCRIPTION + +hot stuff + +=over + +=item * + +Foo + +=item * + +Bar + +=back + +END + my $file = new_file_doc( + name => 'Foo.pod', + content => \$content, + ); + is( $file->documentation, 'Foo', 'POD in __DATA__ section' ); + is( $file->description, 'hot stuff * Foo * Bar' ); + + test_attributes $file, + { + sloc => 1, + slop => 10, + pod_lines => [ [ 6, 19 ], ], + pod => + q[NAME Foo -- An example attribute metaclass for Perl 6 style attributes DESCRIPTION hot stuff * Foo * Bar], + }; +}; + +subtest 'no pod name, various folders' => sub { + my $content = <<'END'; +package Foo::Bar::Baz; + +=head1 DESCRIPTION + +hot stuff + +=over + +=item * + +Foo + +=item * + +Bar + +=back + +END + + foreach my $folder ( 'pod', 'lib', 'docs' ) { + my $file = MetaCPAN::Document::File->new( + author => 'Foo', + content => \$content, + distribution => 'Foo', + name => 'Baz.pod', + path => $folder . '/Foo/Bar/Baz.pod', + release => 'release', + ); + is( $file->documentation, 'Foo::Bar::Baz', + 'Fakes a name when no name section exists in ' + . $folder + . ' folder' ); + is( $file->abstract, undef, 'abstract undef when NAME is missing' ); + + test_attributes $file, + { + sloc => 1, + slop => 8, + pod_lines => [ [ 2, 15 ], ], + pod => q[DESCRIPTION hot stuff * Foo * Bar], + }; + } +}; + +# https://metacpan.org/source/SMUELLER/SelfLoader-1.20/lib/SelfLoader.pm +subtest 'pod with verbatim __DATA__' => sub { + my $content = <<'END'; +package Yo; + +sub name { 42 } + +=head1 Something + +some paragraph .. + +Fully qualified subroutine names are also supported. For example, + + __DATA__ + sub foo::bar {23} + package baz; + sub dob {32} + +will all be loaded correctly by the B, and the B +will ensure that the packages 'foo' and 'baz' correctly have the +B C method when the data after C<__DATA__> is first +parsed. + +=cut + +"code after pod"; + +END + + my $file = new_file_doc( + name => 'Yo.pm', + content => \$content, + ); + + test_attributes $file, + { + sloc => 3, + slop => 12, + pod_lines => [ [ 4, 17 ], ], + pod => + q[Something some paragraph .. Fully qualified subroutine names are also supported. For example, __DATA__ sub foo::bar {23} package baz; sub dob {32} will all be loaded correctly by the SelfLoader, and the SelfLoader will ensure that the packages 'foo' and 'baz' correctly have the SelfLoader "AUTOLOAD" method when the data after "__DATA__" is first parsed.], + }; +}; + +subtest 'pod intermixed with non-pod gibberish' => sub { + + # This is totally made up in an attempt to see how we handle gibberish. + # The decisions of the handling are open to discussion. + + my $badpod = < + +=head1[but no space] +BADPOD + + my $content = < 'Yo.pm', + content => \$content, + ); + + test_attributes $file, { + sloc => 7, + slop => 6, + pod_lines => [ [ 13, 12 ], ], + +# What *should* this parse to? +# * No pod before "Start-Pod". +# * The /^some/ line starts with "some" so the whole line is just text. +# ** Pod::Simple will catch the /\r=[a-z]/ and treat it as a directive: +# *** We probably don't want to remove the line start chars (/\r?\n?/) +# (or we'll throw off lines/blanks/etc...). +# *** If we keep the "\r" but remove the fake directive, +# the "\r" will touch the "=ahem" and the pod document will *start* +# and we'll get lots of text before the pod should start. +# *** So keep everything but mark them so Pod::Simple will skip them. +# ** The "\r" will count as "\s" and get squeezed into a single space. +# * So if /^=moreC/ is kept the will retain the C. +# * When Pod::Simple sees /^head1\[/ it will start the pod document but +# it won't be a heading, it will just be text (along with everything after) +# which obviously was not the intention of the author. So as long as +# the author made a mistake and needs to fix pod: +# ** In the code, if we hide the "invalid" pod then we won't get the whole rest +# of the file being erroneously treated as pod. +# ** Inside the pod, if we left it alone, Pod::Simple would just dump it as +# text. If we mark it, the same thing will happen. + + pod => + q{Start-Pod some =nonpod=ahem =more"=notpod" =head1[but no space] last-word.}, + }; +}; + +subtest 'pod parsing errors are not fatal' => sub { + + my $content = < 'Yo.pm', + content => \$content, + ); + + test_attributes $file, { + description => undef, # no DESCRIPTION pod + documentation => undef, # no pod + + # line counts are separate from the pod parser + sloc => 2, + slop => 2, + pod_lines => [ [ 3, 3 ], ], + pod => q[], + }; +}; + +done_testing; diff --git a/t/document/module.t b/t/document/module.t new file mode 100644 index 000000000..c2c2400b4 --- /dev/null +++ b/t/document/module.t @@ -0,0 +1,63 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Document::Module (); +use Test::More; + +subtest set_associated_pod => sub { + test_associated_pod( 'Squirrel', [qw( lib/Squirrel.pod )], + 'lib/Squirrel.pod' ); + test_associated_pod( 'Squirrel::Face', [qw( lib/Face.pm )], + 'lib/Face.pm' ); + test_associated_pod( 'Squirrel::Face', [qw( bin/sf.pl )], 'bin/sf.pl' ); + + test_associated_pod( 'Squirrel::Face', [qw( bin/sf.pl lib/Face.pm )], + 'lib/Face.pm', 'prefer .pm', ); + + test_associated_pod( 'Squirrel::Face', + [qw( bin/sf.pl lib/Face.pm lib/Squirrel.pod )], + 'lib/Squirrel.pod', 'prefer .pod', ); + + test_associated_pod( + 'Squirrel::Face', [qw( bin/sf.pl lib/Face.pm README.pod )], + 'lib/Face.pm', 'prefer .pm to README.pod', + ); + + test_associated_pod( + 'Squirrel::Face', [qw( Zoob.pod README.pod )], + 'Zoob.pod', 'prefer any .pod to README.pod', + ); + + test_associated_pod( + 'Squirrel::Face', [qw( narf.pl README.pod )], + 'narf.pl', 'prefer .pl to README.pod', + ); + + # This goes along with the Pod::With::Generator tests. + # Since file order is not reliable (there) we can't get a reliable failure + # so test here so that we can ensure the order. + test_associated_pod( + 'Foo::Bar', [qw( a/b.pm x/Foo/Bar.pm lib/Foo/Bar.pm )], + 'lib/Foo/Bar.pm', 'prefer lib/ with matching name to other files', + ); +}; + +{ + + package PodFile; ## no critic + sub new { bless { path => $_[1] }, $_[0]; } + sub path { $_[0]->{path} } + sub name { $_[0]->{name} ||= ( $_[0]->{path} =~ m{([^\/]+)$} )[0] } + sub full_path { '.../' . $_[0]->{path} } +} + +sub test_associated_pod { + my ( $name, $files, $exp, $desc ) = @_; + my $module = MetaCPAN::Document::Module->new( name => $name ); + $module->set_associated_pod( + { $name => [ map { PodFile->new($_) } @$files ] } ); + is $module->associated_pod, ".../$exp", $desc || 'Best pod file selected'; +} + +done_testing; diff --git a/t/lib/MetaCPAN/DarkPAN.pm b/t/lib/MetaCPAN/DarkPAN.pm new file mode 100644 index 000000000..5a6b77824 --- /dev/null +++ b/t/lib/MetaCPAN/DarkPAN.pm @@ -0,0 +1,121 @@ +package MetaCPAN::DarkPAN; + +use MetaCPAN::Moose; + +use CPAN::Repository::Perms (); +use MetaCPAN::TestHelpers qw( write_find_ls ); +use MetaCPAN::Types::TypeTiny qw( Path ); +use MetaCPAN::Util qw( author_dir ); +use OrePAN2::Indexer (); +use OrePAN2::Injector (); +use URI::FromHash qw( uri_object ); + +has base_dir => ( + is => 'ro', + isa => Path, + lazy => 1, + coerce => 1, + default => 't/var/darkpan', +); + +sub run { + my $self = shift; + + $self->base_dir->mkpath; + + my $base_uri = 'http://cpan.metacpan.org'; + + my $injector = OrePAN2::Injector->new( directory => $self->base_dir ); + + # Add this one to test handling of Meta file parse warnings + # MLEHMANN => ['AnyEvent-4.232.tar.gz'], + + my %downloads = ( + MIYAGAWA => [ + 'CPAN-Test-Dummy-Perl5-VersionBump-0.01.tar.gz', + 'CPAN-Test-Dummy-Perl5-VersionBump-0.02.tar.gz', + ], + TINITA => ['HTML-Template-Compiled-1.001.tar.gz'], + DOY => [ 'Try-Tiny-0.21.tar.gz', 'Try-Tiny-0.22.tar.gz', ], + ETHER => [ + 'Try-Tiny-0.23.tar.gz', 'Try-Tiny-0.24.tar.gz', + 'Try-Tiny-0.25-TRIAL.tar.gz', 'Try-Tiny-0.26-TRIAL.tar.gz', + 'Try-Tiny-0.27.tar.gz', + ], + ); + + foreach my $pauseid (%downloads) { + + my $files = $downloads{$pauseid}; + + foreach my $archive ( @{$files} ) { + my $uri = uri_object( + host => 'cpan.metacpan.org', + path => + join( q{/}, 'authors', author_dir($pauseid), $archive ), + scheme => 'http', + ); + + $injector->inject( $uri, { author => $pauseid }, ); + } + } + + my $orepan = OrePAN2::Indexer->new( + directory => $self->base_dir, + metacpan => 1, + ); + $orepan->make_index( no_compress => 1, ); + $self->_write_06perms; + write_find_ls( $self->base_dir ); +} + +sub _write_06perms { + my $self = shift; + + my $perms = CPAN::Repository::Perms->new( { + repository_root => $self->base_dir, + written_by => 'MetaCPAN', + } ); + + my %authors = ( + MIYAGAWA => { + 'CPAN::Test::Dummy::Perl5::VersionBump::Decrease' => 'f', + 'CPAN::Test::Dummy::Perl5::VersionBump::Stay' => 'f', + 'CPAN::Test::Dummy::Perl5::VersionBump::Undef' => 'f', + }, + MLEHMANN => {}, + ); + + foreach my $pauseid ( keys %authors ) { + my $modules = $authors{$pauseid}; + foreach my $module ( keys %{$modules} ) { + $perms->set_perms( $module, $pauseid, $modules->{$module} ); + } + } + + my $modules_dir = $self->base_dir->child('modules'); + $modules_dir->mkpath; + + my $content = $perms->generate_content; + + # work around bug in generate_content() + $content =~ s{,f}{,f\n}g; + + $modules_dir->child('06perms.txt')->spew($content); +} + +sub _write_08pumpkings { + my $self = shift; + + my @pumpkings = qw( + HAARG + ); + + my $content = join '', map "$_\n", @pumpkings; + + $self->base_dir->child(qw(authors 08pumpkings.txt.gz)) + ->spew( { binmode => ':gzip' }, $content ); +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/t/lib/MetaCPAN/Script/MockError.pm b/t/lib/MetaCPAN/Script/MockError.pm new file mode 100644 index 000000000..67193e1b9 --- /dev/null +++ b/t/lib/MetaCPAN/Script/MockError.pm @@ -0,0 +1,102 @@ +package MetaCPAN::Script::MockError; + +use Moose; +use Exception::Class ('MockException'); +use MetaCPAN::Types::TypeTiny qw( Bool Int Str ); + +with 'MetaCPAN::Role::Script', 'MooseX::Getopt'; + +has arg_error_message => ( + init_arg => 'message', + is => 'ro', + isa => Str, + default => "", + documentation => 'mock an Error Message', +); + +has arg_error_code => ( + init_arg => 'error', + is => 'ro', + isa => Int, + default => -1, + documentation => 'mock an Exit Code', +); + +has arg_die => ( + init_arg => 'die', + is => 'ro', + isa => Bool, + default => 0, + documentation => 'mock an Exception', +); + +has arg_handle_error => ( + init_arg => 'handle_error', + is => 'ro', + isa => Bool, + default => 0, + documentation => 'mock a handled error', +); + +has arg_exception => ( + init_arg => 'exception', + is => 'ro', + isa => Bool, + default => 0, + documentation => 'mock an Exception Class', +); + +sub exit_with_die { + my $self = $_[0]; + + if ( $self->arg_error_message ne '' ) { + die( $self->arg_error_message ); + } + else { + die "mock bare die() call"; + } +} + +sub exit_with_error { + my $self = $_[0]; + + if ( $self->arg_error_message ne '' ) { + $self->handle_error( $self->arg_error_message, 1 ); + } + else { + $self->handle_error( "mock bare die() call", 1 ); + } +} + +sub throw_exception { + my $self = $_[0]; + + if ( $self->arg_error_message ne '' ) { + MockException->throw( error => $self->arg_error_message ); + } + else { + MockException->throw( error => "mock an Execption Class" ); + } +} + +sub run { + my $self = shift; + + $self->exit_code( $self->arg_error_code ) + if ( $self->arg_error_code != -1 ); + + $self->exit_with_error + if ( $self->arg_handle_error ); + + $self->exit_with_die if ( $self->arg_die ); + + $self->throw_exception if ( $self->arg_exception ); + + $self->print_error( $self->arg_error_message ) + if ( $self->arg_error_message ne '' ); + +# The run() method is expected to communicate Success to the superior execution level + return ( $self->exit_code == 0 ); +} + +1; diff --git a/t/lib/MetaCPAN/Server/Test.pm b/t/lib/MetaCPAN/Server/Test.pm new file mode 100644 index 000000000..6f50b4d79 --- /dev/null +++ b/t/lib/MetaCPAN/Server/Test.pm @@ -0,0 +1,98 @@ +package MetaCPAN::Server::Test; + +use strict; +use warnings; +use feature qw(state); + +use Carp qw( croak ); +use HTTP::Request::Common qw( DELETE GET POST ); ## no perlimports +use MetaCPAN::ESConfig qw( es_doc_path ); +use MetaCPAN::Server (); +use MetaCPAN::Server::Config (); +use MetaCPAN::Types::TypeTiny qw( ES ); +use MetaCPAN::Util qw( hit_total ); +use Plack::Test; ## no perlimports + +use base 'Exporter'; +our @EXPORT_OK = qw( + POST GET DELETE + es + es_result + test_psgi app + query +); + +# Begin the load-order dance. + +my $app; + +sub _load_app { + + # Delay loading. + $app ||= MetaCPAN::Server->to_app; +} + +sub prepare_user_test_data { + _load_app(); +} + +sub app { + + # Make sure this is done before the app is used. + prepare_user_test_data(); + + return $app; +} + +sub es { + state $es = do { + my $c = MetaCPAN::Server::Config::config(); + ES->assert_coerce( $c->{elasticsearch_servers} ); + }; +} + +sub query { + state $query = MetaCPAN::Query->new( es => es() ); +} + +sub es_result { + my ( $type, $query, $size ) = @_; + $size //= wantarray ? 999 : 1; + if ( !wantarray && $size != 1 ) { + croak "multiple results requested with scalar return!"; + } + my $res = es()->search( + es_doc_path($type), + body => { + size => ( wantarray ? 999 : 1 ), + query => $query, + }, + ); + my @hits = map $_->{_source}, @{ $res->{hits}{hits} }; + if ( !wantarray ) { + croak "query did not return a single result" + if hit_total($res) != 1; + return $hits[0]; + } + return @hits; +} + +1; + +=pod + +# ABSTRACT: Test class for MetaCPAN::Web + +=head1 EXPORTS + +=head2 GET + +L + +=head2 test_psgi + +L + +=head2 app + +Returns the L psgi app. diff --git a/t/lib/MetaCPAN/TestHelpers.pm b/t/lib/MetaCPAN/TestHelpers.pm new file mode 100644 index 000000000..094af2314 --- /dev/null +++ b/t/lib/MetaCPAN/TestHelpers.pm @@ -0,0 +1,167 @@ +package MetaCPAN::TestHelpers; + +use strict; +use warnings; + +package # no_index + MetaCPAN::TestHelpers; + +use Cpanel::JSON::XS qw( decode_json encode_json ); +use File::Copy qw( copy ); +use File::pushd qw( pushd ); +use MetaCPAN::Server::Config (); +use MetaCPAN::Util qw( root_dir ); +use Path::Tiny qw( path ); +use Test::More; +use Test::Routine::Util qw( run_tests ); +use Try::Tiny qw( catch finally try ); + +use base 'Exporter'; +our @EXPORT = qw( + catch + decode_json_ok + encode_json + fakecpan_configs_dir + fakecpan_dir + finally + get_config + hex_escape + multiline_diag + run_tests + test_cache_headers + test_distribution + test_release + tmp_dir + try + write_find_ls +); + +=head1 EXPORTS + +=head2 multiline_diag + + multiline_diag(file1 => $mutliple_lines, file2 => $long_text); + +Prints out multiline text blobs in an way that's (hopefully) easier to read. +Passes strings through L. + +=cut + +sub multiline_diag { + while ( my ( $name, $str ) = splice( @_, 0, 2 ) ) { + $str =~ s/^/ |/mg; + diag "$name:\n" . hex_escape($str) . "\n"; + } +} + +=head2 hex_escape + +Replaces many uncommon bytes with the equivalent \x{deadbeef} escape. + +=cut + +sub hex_escape { + my $s = shift; + $s =~ s/([^a-zA-Z0-9[:punct:] \t\n])/sprintf("\\x{%x}", ord $1)/ge; + $s; +} + +sub decode_json_ok { + my ($json) = @_; + $json = $json->content + if try { $json->isa('HTTP::Response') }; + ok( my $obj = try { decode_json($json) }, 'valid json' ); + return $obj; +} + +sub test_distribution { + my ( $name, $args, $desc ) = @_; + run_tests( + $desc || "Distribution data for $name", + ['MetaCPAN::Tests::Distribution'], + { name => $name, %$args } + ); +} + +sub test_release { + my $release = {}; + + # If the first arg is a string, treat it like 'AUTHOR/Release-Name'. + if ( !ref( $_[0] ) ) { + my ( $author, $name ) = split m{/}, shift; + $release = { name => $name, author => $author }; + } + + my ( $args, $desc ) = @_; + $args = { %$release, %$args }; + run_tests( $desc || "Release data for $args->{author}/$args->{name}", + ['MetaCPAN::Tests::Release'], $args, ); +} + +sub get_config { + return MetaCPAN::Server::Config::config(); +} + +sub tmp_dir { + my $dir = path( root_dir(), 'var', 't', 'tmp' ); + $dir->mkpath; + return $dir; +} + +sub fakecpan_dir { + my $dir = tmp_dir(); + my $fakecpan = $dir->child('fakecpan'); + $fakecpan->mkpath; + return $fakecpan; +} + +sub fakecpan_configs_dir { + my $source = path( root_dir(), 'test-data', 'fakecpan' ); + $source->mkpath; + return $source; +} + +sub test_cache_headers { + my ( $res, $conf ) = @_; + + is( + $res->header('Cache-Control'), + $conf->{cache_control}, + "Cache Header: Cache-Control ok" + ) if exists $conf->{cache_control}; + + is( + $res->header('Surrogate-Key'), + $conf->{surrogate_key}, + "Cache Header: Surrogate-Key ok" + ) if exists $conf->{surrogate_key}; + + is( + $res->header('Surrogate-Control'), + $conf->{surrogate_control}, + "Cache Header: Surrogate-Control ok" + ) if exists $conf->{surrogate_control}; +} + +sub write_find_ls { + my $cpan_dir = shift; + + my $indices = $cpan_dir->child('indices'); + $indices->mkpath; + + my $find_ls = $indices->child('find-ls.gz')->openw(':gzip'); + + my $chdir = pushd($cpan_dir); + + open my $fh, '-|', 'find', 'authors', '-ls' + or die "can't run find: $!"; + + copy $fh, $find_ls; + + close $fh; + close $find_ls; + + return; +} + +1; diff --git a/t/lib/MetaCPAN/TestServer.pm b/t/lib/MetaCPAN/TestServer.pm new file mode 100644 index 000000000..da329b7a1 --- /dev/null +++ b/t/lib/MetaCPAN/TestServer.pm @@ -0,0 +1,258 @@ +package MetaCPAN::TestServer; + +use MetaCPAN::Moose; + +use MetaCPAN::ESConfig qw( es_config ); +use MetaCPAN::Script::Author (); +use MetaCPAN::Script::Cover (); +use MetaCPAN::Script::CPANTestersAPI (); +use MetaCPAN::Script::Favorite (); +use MetaCPAN::Script::First (); +use MetaCPAN::Script::Latest (); +use MetaCPAN::Script::Mapping (); +use MetaCPAN::Script::Mirrors (); +use MetaCPAN::Script::Package (); +use MetaCPAN::Script::Permission (); +use MetaCPAN::Script::Release (); +use MetaCPAN::Server (); +use MetaCPAN::Server::Config (); +use MetaCPAN::TestHelpers qw( fakecpan_dir ); +use MetaCPAN::Types::TypeTiny qw( HashRef Path ); +use MetaCPAN::Util qw( true false ); +use MooseX::Types::ElasticSearch qw( ES ); +use Test::More; + +has es_client => ( + is => 'ro', + isa => ES, + coerce => 1, + lazy => 1, + builder => '_build_es_client', +); + +has _config => ( + is => 'ro', + isa => HashRef, + lazy => 1, + builder => '_build_config', +); + +has _cpan_dir => ( + is => 'ro', + isa => Path, + init_arg => 'cpan_dir', + coerce => 1, + default => sub { fakecpan_dir() }, +); + +sub setup { + my $self = shift; + + $self->es_client; + $self->put_mappings; +} + +sub _build_config { + my $self = shift; + + # don't know why get_config is not imported by this point + my $config = MetaCPAN::TestHelpers::get_config(); + + $config->{es} = $self->es_client; + $config->{cpan} = $self->_cpan_dir; + return $config; +} + +sub _build_es_client { + my $self = shift; + + my $es = ES->assert_coerce( + MetaCPAN::Server::Config::config()->{elasticsearch_servers}, ); + + ok( $es, 'got Search::Elasticsearch object' ); + + note( Test::More::explain( { 'Elasticsearch info' => $es->info } ) ); + + return $es; +} + +sub wait_for_es { + my $self = shift; + + $self->es_client->cluster->health( + wait_for_status => 'yellow', + timeout => '30s' + ); + $self->es_client->indices->refresh; +} + +sub check_mappings { + my $self = $_[0]; + my %indices = ( map +( $_ => 'yellow' ), @{ es_config->all_indexes } ); + + local @ARGV = qw(mapping --show_cluster_info); + + my $mapping + = MetaCPAN::Script::Mapping->new_with_options( $self->_config ); + + ok( $mapping->run, 'show cluster info' ); + + note( Test::More::explain( + { 'indices_info' => \%{ $mapping->indices_info } } + ) ); + + subtest 'only configured indices' => sub { + ok( defined $indices{$_}, "indice '$_' is configured" ) + foreach ( keys %{ $mapping->indices_info } ); + }; + subtest 'verify index health' => sub { + foreach ( keys %indices ) { + ok( defined $mapping->indices_info->{$_}, + "index '$_' was created" ); + is( $mapping->indices_info->{$_}->{'health'}, + $indices{$_}, "index '$_' correct state '$indices{$_}'" ); + } + }; +} + +sub put_mappings { + my $self = shift; + + local @ARGV = qw(mapping --delete --all); + ok( MetaCPAN::Script::Mapping->new_with_options( $self->_config )->run, + 'put mapping' ); + $self->check_mappings; + $self->wait_for_es; +} + +sub index_releases { + my $self = shift; + my %args = @_; + + local @ARGV = ( + 'release', $ENV{MC_RELEASE} ? $ENV{MC_RELEASE} : $self->_cpan_dir + ); + ok( + MetaCPAN::Script::Release->new_with_options( %{ $self->_config }, + %args )->run, + 'index releases' + ); +} + +sub set_latest { + my $self = shift; + local @ARGV = ('latest'); + ok( MetaCPAN::Script::Latest->new_with_options( $self->_config )->run, + 'latest' ); +} + +sub set_first { + my $self = shift; + local @ARGV = ('first'); + ok( MetaCPAN::Script::First->new_with_options( $self->_config )->run, + 'first' ); +} + +sub index_authors { + my $self = shift; + + local @ARGV = ('author'); + ok( MetaCPAN::Script::Author->new_with_options( $self->_config )->run, + 'index authors' ); +} + +# Right now this test requires you to have an internet connection. If we can +# get a sample db then we can run this with the '--skip-download' option. + +sub index_cpantesters { + my $self = shift; + + local @ARGV = ('cpantestersapi'); + ok( + MetaCPAN::Script::CPANTestersAPI->new_with_options( $self->_config ) + ->run, + 'index cpantesters' + ); +} + +sub index_mirrors { + my $self = shift; + + local @ARGV = ('mirrors'); + ok( MetaCPAN::Script::Mirrors->new_with_options( $self->_config )->run, + 'index mirrors' ); +} + +sub index_cover { + my $self = shift; + + local @ARGV = ( 'cover', '--json_file', 't/var/cover.json' ); + ok( MetaCPAN::Script::Cover->new_with_options( $self->_config )->run, + 'index cover' ); +} + +sub index_permissions { + my $self = shift; + + ok( + MetaCPAN::Script::Permission->new_with_options( + %{ $self->_config }, + + # Eventually maybe move this to use the DarkPAN 06perms + #cpan => MetaCPAN::DarkPAN->new->base_dir, + )->run, + 'index permissions' + ); +} + +sub index_packages { + my $self = shift; + + ok( + MetaCPAN::Script::Package->new_with_options( + %{ $self->_config }, + + # Eventually maybe move this to use the DarkPAN 06perms + #cpan => MetaCPAN::DarkPAN->new->base_dir, + )->run, + 'index packages' + ); +} + +sub index_favorite { + my $self = shift; + + ok( + MetaCPAN::Script::Favorite->new_with_options( + %{ $self->_config }, + + # Eventually maybe move this to use the DarkPAN 06perms + #cpan => MetaCPAN::DarkPAN->new->base_dir, + )->run, + 'index favorite' + ); +} + +sub prepare_user_test_data { + my $self = shift; + ok( + my $user = MetaCPAN::Server->model('ESModel')->doc('account')->put( { + access_token => [ { client => 'testing', token => 'testing' } ] + } ), + 'prepare user' + ); + ok( $user->add_identity( { name => 'pause', key => 'MO' } ), + 'add pause identity' ); + ok( $user->put( { refresh => true } ), 'put user' ); + + ok( + MetaCPAN::Server->model('ESModel')->doc('account')->put( + { access_token => [ { client => 'testing', token => 'bot' } ] }, + { refresh => true } + ), + 'put bot user' + ); +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/t/lib/MetaCPAN/Tests/Controller/Search/DownloadURL.pm b/t/lib/MetaCPAN/Tests/Controller/Search/DownloadURL.pm new file mode 100644 index 000000000..d4655edf1 --- /dev/null +++ b/t/lib/MetaCPAN/Tests/Controller/Search/DownloadURL.pm @@ -0,0 +1,46 @@ +package MetaCPAN::Tests::Controller::Search::DownloadURL; + +use strict; +use warnings; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok ); +use Moose; +use Test::More; + +sub run { + test_psgi app, sub { + my $cb = shift; + + my $module = 'CPAN::Test::Dummy::Perl5::VersionBump::Decrease'; + + # test ES script using doc['blah'] value + ok( my $res = $cb->( GET '/download_url/' . $module ), + "GET $module" ); + my $json = decode_json_ok($res); + + diag explain $json; + + # my $got + # = [ map { $_->{_source}{documentation} } + # @{ $json->{hits}{hits} } ]; + # + # is_deeply $got, [ + # qw( + # Multiple::Modules + # Multiple::Modules::A + # Multiple::Modules::B + # Multiple::Modules::RDeps + # Multiple::Modules::Tester + # Multiple::Modules::RDeps::A + # Multiple::Modules::RDeps::Deprecated + # ) + # ], + # 'results are sorted by module name length' + # or diag( Test::More::explain($got) ); + # } + }; +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/t/lib/MetaCPAN/Tests/Distribution.pm b/t/lib/MetaCPAN/Tests/Distribution.pm new file mode 100644 index 000000000..5942a859e --- /dev/null +++ b/t/lib/MetaCPAN/Tests/Distribution.pm @@ -0,0 +1,33 @@ +package MetaCPAN::Tests::Distribution; +use Test::More; +use Test::Routine; +use version; +use MetaCPAN::Types::TypeTiny qw( Str ); + +with qw( MetaCPAN::Tests::Query ); + +sub _build_type {'distribution'} + +sub _build_search { + my $self = shift; + return { term => { name => $self->name } }; +} + +my @attrs = qw( + name +); + +has [@attrs] => ( + is => 'ro', + isa => Str, +); + +test 'distribution attributes' => sub { + my ($self) = @_; + + foreach my $attr (@attrs) { + is $self->data->{$attr}, $self->$attr, $attr; + } +}; + +1; diff --git a/t/lib/MetaCPAN/Tests/Extra.pm b/t/lib/MetaCPAN/Tests/Extra.pm new file mode 100644 index 000000000..b875017de --- /dev/null +++ b/t/lib/MetaCPAN/Tests/Extra.pm @@ -0,0 +1,31 @@ +package MetaCPAN::Tests::Extra; +use Test::More; +use Test::Routine; +use MetaCPAN::Types::TypeTiny qw( CodeRef ); + +around BUILDARGS => sub { + my ( $orig, $class, @args ) = @_; + my $attr = $class->$orig(@args); + + delete $attr->{_expect}{extra_tests}; + + return $attr; +}; + +has _extra_tests => ( + is => 'ro', + isa => CodeRef, + init_arg => 'extra_tests', + predicate => 'has_extra_tests', +); + +test 'extra tests' => sub { + my ($self) = @_; + + plan skip_all => 'No extra tests defined' + if !$self->has_extra_tests; + + $self->_extra_tests->($self); +}; + +1; diff --git a/t/lib/MetaCPAN/Tests/PSGI.pm b/t/lib/MetaCPAN/Tests/PSGI.pm new file mode 100644 index 000000000..dc1e254a5 --- /dev/null +++ b/t/lib/MetaCPAN/Tests/PSGI.pm @@ -0,0 +1,22 @@ +package MetaCPAN::Tests::PSGI; + +use Test::More; +use Test::Routine; + +use MetaCPAN::Server::Test qw( app test_psgi ); + +sub psgi_app { + my ( $self, $sub ) = @_; + my @result; + + test_psgi( + app => app(), + client => sub { + @result = $sub->(@_); + }, + ); + + return $result[0]; +} + +1; diff --git a/t/lib/MetaCPAN/Tests/Query.pm b/t/lib/MetaCPAN/Tests/Query.pm new file mode 100644 index 000000000..4a2f034f4 --- /dev/null +++ b/t/lib/MetaCPAN/Tests/Query.pm @@ -0,0 +1,117 @@ +package MetaCPAN::Tests::Query; + +use Test::Routine; + +use MetaCPAN::ESConfig qw( es_doc_path ); +use MetaCPAN::Server::Test (); +use MetaCPAN::Types::TypeTiny qw( ES HashRef Str ); +use Test::More; +use Try::Tiny qw( try ); + +around BUILDARGS => sub { + my ( $orig, $class, @args ) = @_; + my $attr = $class->$orig(@args); + + my $expect = {%$attr}; + + return { _expect => $expect, %$attr }; +}; + +with qw( + MetaCPAN::Tests::Extra + MetaCPAN::Tests::PSGI +); + +has _type => ( + is => 'ro', + isa => Str, + builder => '_build_type', +); + +has es => ( + is => 'ro', + isa => ES, + lazy => 1, + default => sub { MetaCPAN::Server::Test::es() }, +); + +has search => ( + is => 'ro', + isa => HashRef, + lazy => 1, + builder => '_build_search', +); + +sub _do_search { + my ($self) = @_; + my $query = $self->search; + my $res = $self->es->search( + es_doc_path( $self->_type ), + body => { + query => $query, + size => 1, + }, + ); + my $hit = $res->{hits}{hits}[0]; + return $hit ? $hit->{_source} : undef; +} + +has data => ( + is => 'ro', + predicate => 'has_data', + lazy => 1, + default => sub { $_[0]->_do_search }, +); + +has _expectations => ( + is => 'ro', + isa => HashRef, + init_arg => '_expect', +); + +test 'expected attributes' => sub { + my ($self) = @_; + my $exp = $self->_expectations; + my $data = $self->data; + + foreach my $key ( sort keys %$exp ) { + + # Skip attributes of the test class that aren't attributes of the model. + #next unless exists $data->{$key}; + + is_deeply $data->{$key}, $exp->{$key}, $key + or diag Test::More::explain $data->{$key}; + } +}; + +around run_test => sub { + my ( $orig, $self, @args ) = @_; + + # If we haven't performed the search yet, do it now. + if ( !$self->has_data ) { + + # TODO: An attribute that says to expect to not find (and return ok). + + ok( $self->data, 'Search successful' ) + or diag( Test::More::explain( $self->search ) ); + } + + # If the object wasn't found (either just now or in a previous test), + # don't proceed with the tests because they will all fail miserably + # (can't call method on undefined value, etc.). + if ( !defined( $self->data ) ) { + my $desc = 'Search failed; cannot proceed'; + + # We can make the test output a little nicer + # (but this API might not be documented.) + try { $desc .= ' with test: ' . $args[0]->name }; + + # Show a failure and short-circuit. + return ok( 0, $desc ); + } + + # Continue with Test::Routine's subtest. + return $self->$orig(@args); +}; + +1; diff --git a/t/lib/MetaCPAN/Tests/Release.pm b/t/lib/MetaCPAN/Tests/Release.pm new file mode 100644 index 000000000..8f7c7a6c7 --- /dev/null +++ b/t/lib/MetaCPAN/Tests/Release.pm @@ -0,0 +1,247 @@ +package MetaCPAN::Tests::Release; + +use Test::Routine; + +use version; + +use HTTP::Request::Common qw( GET ); +use List::Util (); +use MetaCPAN::ESConfig qw( es_doc_path ); +use MetaCPAN::Types::TypeTiny qw( ArrayRef HashRef Str ); +use Test::More; + +with qw( MetaCPAN::Tests::Query ); + +sub _build_type {'release'} + +sub _build_search { + my ($self) = @_; + return { + bool => { + must => [ + { term => { author => $self->author } }, + { term => { name => $self->name } }, + ] + }, + }; +} + +around BUILDARGS => sub { + my ( $orig, $self, @args ) = @_; + my $attr = $self->$orig(@args); + + if ( !$attr->{distribution} + && !$attr->{version} + && $attr->{name} + && $attr->{name} =~ /(.+?)-([0-9._]+)$/ ) + { + @$attr{qw( distribution version )} = ( $1, $2 ); + } + + # We handle these specially. + delete $attr->{_expect}{tests}; + delete $attr->{_expect}{modules}; + + return $attr; +}; + +my @attrs = qw( + author distribution version +); + +has [@attrs] => ( + is => 'ro', + isa => Str, +); + +has version_numified => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + + # This is much simpler than what we do in the indexer. + # If we need to use Util we must need more tests. + my $v = $_[0]->version; + return 0 unless $v; + return 'version'->parse($v)->numify + 0; + }, +); + +has files => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + builder => '_build_files', +); + +sub _build_files { + my ($self) = @_; + return $self->filter_files(); +} + +sub file_content { + my ( $self, $file ) = @_; + + # Accept a file object (from es) or just a string path. + my $path = ref $file ? $file->{path} : $file; + + # I couldn't get the Source model to work outside the app (I got + # "No handler available for type 'application/octet-stream'", + # strangely), so just do the http request. + return $self->psgi_app( sub { + shift->( GET "/source/$self->{author}/$self->{name}/$path" )->content; + } ); +} + +sub file_by_path { + my ( $self, $path ) = @_; + my $file = List::Util::first { $_->{path} eq $path } @{ $self->files }; + ok $file, "found file '$path'"; + return $file; +} + +has module_files => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + builder => '_build_module_files', +); + +sub _build_module_files { + my ($self) = @_; + return $self->filter_files( + [ { exists => { field => 'module.name' } }, ] ); +} + +sub filter_files { + my ( $self, $add_filters ) = @_; + + $add_filters = [$add_filters] + if $add_filters && ref($add_filters) ne 'ARRAY'; + + my $release = $self->data; + my $res = $self->es->search( + es_doc_path('file'), + body => { + query => { + bool => { + must => [ + { term => { 'author' => $release->{author} } }, + { term => { 'release' => $release->{name} } }, + @{ $add_filters || [] }, + ], + }, + }, + size => 100, + }, + ); + return [ map $_->{_source}, @{ $res->{hits}{hits} } ]; +} + +has modules => ( + is => 'ro', + isa => HashRef, + default => sub { +{} }, +); + +sub pod { + my ( $self, $path, $type ) = @_; + my $query = $type ? "?content-type=$type" : q[]; + return $self->psgi_app( sub { + shift->( GET "/pod/$self->{author}/$self->{name}/${path}${query}" ) + ->content; + } ); +} + +# The default status for a release is 'cpan' +# but many test dists only have one version so 'latest' is more likely. +has status => ( + is => 'ro', + isa => Str, + default => 'latest', +); + +has archive => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { shift->name . '.tar.gz' }, +); + +has name => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my ($self) = @_; + $self->distribution . q[-] . $self->version; + }, +); + +has tests => ( + is => 'ro', + predicate => 'expects_tests', +); + +sub has_tests_ok { + my ($self) = @_; + my $tests = $self->data->{tests}; + + # Don't test the actual numbers since we copy this out of the real + # database as a live test case. + + is ref($tests), 'HASH', 'hashref of tests'; + + my @results = qw( pass fail na unknown ); + + ok exists( $tests->{$_} ), "has '$_' results" for @results; + + ok List::Util::sum( map { $tests->{$_} } @results ) > 0, + 'has some results'; +} + +push @attrs, qw( version_numified status archive name ); + +test 'release attributes' => sub { + my ($self) = @_; + + foreach my $attr (@attrs) { + is $self->data->{$attr}, $self->$attr, "release $attr"; + } + + if ( $self->expects_tests ) { + if ( $self->tests eq '1' ) { + $self->has_tests_ok; + } + else { + is_deeply $self->data->{tests}, $self->tests, 'test results'; + } + } +}; + +test 'modules in Packages-1.103' => sub { + my ($self) = @_; + + plan skip_all => 'No modules specified for testing' + unless scalar keys %{ $self->modules }; + + my %module_files + = map { ( $_->{path} => $_->{module} ) } @{ $self->module_files }; + + foreach my $path ( sort keys %{ $self->modules } ) { + my $desc = "File '$path' has expected modules"; + my $got_modules = delete $module_files{$path} || []; + my $got = [ map +{%$_}, @$got_modules ]; + $_->{associated_pod} //= undef for @$got; + + # We may need to sort modules by name, I'm not sure if order is reliable. + is_deeply $got, $self->modules->{$path}, $desc + or diag Test::More::explain($got); + } + + is( scalar keys %module_files, 0, 'all module files tested' ) + or diag Test::More::explain \%module_files; +}; + +1; diff --git a/t/lib/MetaCPAN/Tests/UserAgent.pm b/t/lib/MetaCPAN/Tests/UserAgent.pm new file mode 100644 index 000000000..36e0b96e3 --- /dev/null +++ b/t/lib/MetaCPAN/Tests/UserAgent.pm @@ -0,0 +1,102 @@ +use strict; +use warnings; + +package MetaCPAN::Tests::UserAgent; + +use Test::More; +use Test::Routine; + +use HTTP::Cookies (); +use HTTP::Request (); +use LWP::UserAgent (); + +has cb => ( + is => 'ro', + required => 1, +); + +has responses => ( + is => 'ro', + init_arg => undef, + default => sub { [] }, +); + +has ua => ( + is => 'ro', + lazy => 1, + default => sub { + LWP::UserAgent->new( + + # Don't follow redirect since we're not actually listening on + # localhost:80 (we need to pass to $cb). + max_redirect => 0, + ); + }, +); + +has cookie_jar => ( + is => 'ro', + default => sub { + HTTP::Cookies->new(); + }, +); + +sub last_response { + my ($self) = @_; + $self->responses->[-1]; +} + +sub redirect_uri { + my ( $self, $response ) = @_; + $response ||= $self->last_response; + return URI->new( $response->header('location') ); +} + +sub follow_redirect { + my ( $self, $response ) = @_; + $response ||= $self->last_response; + + return $self->request( $self->request_from_redirect($response) ); +} + +sub request_from_redirect { + my ( $self, $response ) = @_; + return HTTP::Request->new( GET => $self->redirect_uri($response) ); +} + +# This can be overridden if tests have better criteria. +sub request_is_external { + my ( $self, $request ) = @_; + + # If it's a generic URI (no scheme) it was probably "/controller/action". + return 0 if !$request->uri->scheme; + + # The tests shouldn't interact with a webserver on localhost:80 + # so assume that the request was built without host/port + # and it was intended for the plack cb. + return $request->uri->host_port ne 'localhost:80'; +} + +sub request { + my ( $self, $request ) = @_; + my $response; + + # Use UA to request against external servers. + if ( $self->request_is_external($request) ) { + $response = $self->ua->request($request); + } + + # Use the plack test cb for requests against our app. + else { + # We need to preserve the cookies so the API knows who we are. + $self->cookie_jar->add_cookie_header($request); + $response = $self->cb->($request); + $self->cookie_jar->extract_cookies($response); + } + + push @{ $self->responses }, $response; + + return $response; +} + +1; diff --git a/t/lib/Module/Faker/Dist/WithPerl.pm b/t/lib/Module/Faker/Dist/WithPerl.pm new file mode 100644 index 000000000..277fe7ffe --- /dev/null +++ b/t/lib/Module/Faker/Dist/WithPerl.pm @@ -0,0 +1,44 @@ +package # no_index + Module::Faker::Dist::WithPerl; + +use Moose; +extends 'Module::Faker::Dist'; + +use Encode qw( encode_utf8 ); + +around append_for => sub { + my ( $orig, $self, $filename ) = @_; + return [ + # $orig normally expects utf-8 (yaml, json, etc) + # but the reason for this subclass is to allow other encodings + map { + utf8::is_utf8( $_->{content} ) + ? encode_utf8( $_->{content} ) + : $_->{content} + } + grep { $filename eq $_->{file} } @{ $self->append } + ]; +}; + +around from_file => sub { + my ( $orig, $self, $filename ) = @_; + + # I'm not thrilled abot this but found it necessary for mixed encoding dists + return $self->_from_perl_file($filename) + if $filename =~ /\.pl$/; + + return $self->$orig($filename); +}; + +# be consistent with _from_meta_file so that the hash structures can be consistent +sub _from_perl_file { + my ( $self, $filename ) = @_; + + my $data = do($filename); + + my $extra = ( delete $data->{X_Module_Faker} ) || {}; + my $dist = $self->new( { %$data, %$extra } ); +} + +__PACKAGE__->meta->make_immutable; +1; diff --git a/t/metacpan.t b/t/metacpan.t deleted file mode 100644 index 3103c056b..000000000 --- a/t/metacpan.t +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/perl - -use Data::Dump qw( dump ); -use Modern::Perl; -use Test::More qw( no_plan ); - -require_ok( 'MetaCPAN' ); -require_ok( 'MetaCPAN::Dist' ); - -my $extract = MetaCPAN->new; -ok( $extract, "got an extract object" ); - -my $file = $extract->open_pkg_index; -isa_ok( $file, 'IO::File'); - -my $index = $extract->pkg_index; -my $modules = keys %{ $index }; -cmp_ok( $modules , '>', '75000', "have $modules modules in index"); - -ok ( $extract->module_rs, "got module resultset" ); - -if ( $extract->module_rs->search({})->count == 0 ) { - diag("am building metadata rows"); - ok( $extract->populate, "can populate db"); -} - - diff --git a/t/model/archive.t b/t/model/archive.t new file mode 100644 index 000000000..9cb2a1f7e --- /dev/null +++ b/t/model/archive.t @@ -0,0 +1,112 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Digest::SHA qw( sha1_hex ); +use MetaCPAN::TestHelpers qw( fakecpan_dir ); +use Test::Deep qw( cmp_bag ); +use Test::Fatal qw( exception ); +use Test::More; + +my $CLASS = 'MetaCPAN::Model::Archive'; +require_ok $CLASS; + +subtest 'missing required arguments' => sub { + like exception { $CLASS->new }, qr/^Attribute \(file\) is required/; +}; + +subtest 'file does not exist' => sub { + my $file = 'hlaglhalghalghj.blah'; + my $archive = $CLASS->new( file => $file ); + + like exception { $archive->files }, qr{$file does not exist}; +}; + +subtest 'archive extraction' => sub { + my %want = ( + 'Some-1.00-TRIAL/lib/Some.pm' => + '2f806b4c7413496966f52ef353984dde10b6477b', + 'Some-1.00-TRIAL/Makefile.PL' => + 'bc7f47a8e0e9930f41c06e150c7d229cfd3feae7', + 'Some-1.00-TRIAL/t/00-nop.t' => + '2eba5fd5f9e08a9dcc1c5e2166b7d7d958caf377', + 'Some-1.00-TRIAL/META.json' => qr/"meta-spec"/, + 'Some-1.00-TRIAL/META.yml' => qr/provides:/, + 'Some-1.00-TRIAL/MANIFEST' => + 'e93d21831fb3d3cac905dbe852ba1a4a07abd991', + ); + + my $archive = $CLASS->new( + file => fakecpan_dir->child( + '/authors/id/L/LO/LOCAL/Some-1.00-TRIAL.tar.gz')->stringify + ); + + ok !$archive->is_impolite; + ok !$archive->is_naughty; + + cmp_bag $archive->files, [ keys %want ]; + + my $dir = $archive->extract; + for my $file ( keys %want ) { + my $content = $dir->child($file)->slurp; + if ( ref $want{$file} ) { + like $content, $want{$file}, "content of $file"; + } + else { + my $digest = sha1_hex($content); + is $digest, $want{$file}, "content of $file"; + } + } +}; + +subtest 'temp cleanup' => sub { + my $tempdir; + + { + my $archive = $CLASS->new( + file => fakecpan_dir->child( + 'authors/id/L/LO/LOCAL/Some-1.00-TRIAL.tar.gz')->stringify + ); + + $tempdir = $archive->extract; + ok -d $tempdir; + + # stringify to get rid of the temp object so $tempdir doesn't keep + # it alive + $tempdir = "$tempdir"; + } + + ok !-d $tempdir; +}; + +subtest 'extract once' => sub { + my $archive = $CLASS->new( + file => fakecpan_dir->child( + 'authors/id/L/LO/LOCAL/Some-1.00-TRIAL.tar.gz')->stringify + ); + + is $archive->extract, $archive->extract; +}; + +subtest 'set extract dir' => sub { + my $temp = File::Temp->newdir; + + { + my $archive = $CLASS->new( + file => fakecpan_dir->child( + 'authors/id/L/LO/LOCAL/Some-1.00-TRIAL.tar.gz')->stringify, + extract_dir => $temp->dirname + ); + + my $dir = $archive->extract_dir; + + isa_ok $dir, 'Path::Tiny'; + is $dir, $temp; + is $archive->extract, $temp; + ok -s $dir->child('Some-1.00-TRIAL/META.json'); + } + + ok -e $temp, q[Path::Tiny doesn't clean up directories it was handed]; +}; + +done_testing; diff --git a/t/model/email/pause.t b/t/model/email/pause.t new file mode 100644 index 000000000..34929249c --- /dev/null +++ b/t/model/email/pause.t @@ -0,0 +1,52 @@ +use strict; +use warnings; + +## no critic (Modules::RequireFilenameMatchesPackage) +package Author; + +use MetaCPAN::Moose; + +use MetaCPAN::Types::TypeTiny qw( ArrayRef Str ); + +has name => ( + is => 'ro', + isa => Str, + init_arg => 'name', +); + +has email => ( + is => 'ro', + isa => ArrayRef [Str], + required => 1, +); + +__PACKAGE__->meta->make_immutable; +1; + +package main; + +BEGIN { $ENV{EMAIL_SENDER_TRANSPORT} = 'Test' } + +use Test::More; + +use MetaCPAN::Model::Email::PAUSE (); + +my $author = Author->new( + name => 'Olaf Alders', + email => ['oalders@metacpan.org'], +); + +my $email = MetaCPAN::Model::Email::PAUSE->new( + author => $author, + url => URI->new('http://example.com'), +); + +ok( $email->_email_body, 'email_body' ); +ok( $email->send, 'send email' ); +diag $email->_email_body; + +my @messages = Email::Sender::Simple->default_transport->deliveries; +is( @messages, 1, '1 message sent' ); + +done_testing(); +1; diff --git a/t/model/release.t b/t/model/release.t new file mode 100644 index 000000000..f361696a0 --- /dev/null +++ b/t/model/release.t @@ -0,0 +1,25 @@ +use strict; +use warnings; +use lib 't/lib'; + +use File::Temp (); +use LWP::Simple qw( getstore ); +use MetaCPAN::Model::Release (); +use MetaCPAN::TestHelpers qw( get_config ); +use Test::More; +use Test::RequiresInternet( 'metacpan.org' => 'https' ); + +my $config = get_config(); +my $url + = 'https://cpan.metacpan.org/authors/id/D/DC/DCANTRELL/Acme-Pony-1.1.2.tar.gz'; + +my $archive_file = File::Temp->new; +getstore $url, $archive_file->filename; +ok -s $archive_file->filename; + +my $release + = MetaCPAN::Model::Release->new( file => $archive_file->filename ); + +is $release->file, $archive_file->filename; + +done_testing(); diff --git a/t/model/release/dependencies.t b/t/model/release/dependencies.t new file mode 100644 index 000000000..99a6b0647 --- /dev/null +++ b/t/model/release/dependencies.t @@ -0,0 +1,56 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Model::Release (); +use MetaCPAN::TestHelpers qw( fakecpan_dir get_config ); +use Test::Deep qw( cmp_bag ); +use Test::More; + +my $config = get_config(); + +subtest 'basic dependencies' => sub { + my $file + = fakecpan_dir->child( + '/authors/id/M/MS/MSCHWERN/Prereqs-Basic-0.01.tar.gz'); + + my $release = MetaCPAN::Model::Release->new( file => $file ); + + my $dependencies = $release->dependencies; + + cmp_bag $dependencies, + [ + { + phase => 'build', + relationship => 'requires', + module => 'For::Build::Requires1', + version => 2.45 + }, + { + phase => 'configure', + relationship => 'requires', + module => 'For::Configure::Requires1', + version => 72 + }, + { + phase => 'runtime', + relationship => 'requires', + module => 'For::Runtime::Requires1', + version => 0 + }, + { + phase => 'runtime', + relationship => 'requires', + module => 'For::Runtime::Requires2', + version => 1.23 + }, + { + phase => 'runtime', + relationship => 'recommends', + module => 'For::Runtime::Recommends1', + version => 0 + } + ]; +}; + +done_testing; diff --git a/t/model/release/metadata.t b/t/model/release/metadata.t new file mode 100644 index 000000000..b1c7d91d4 --- /dev/null +++ b/t/model/release/metadata.t @@ -0,0 +1,44 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Model::Release (); +use MetaCPAN::TestHelpers qw( fakecpan_dir get_config ); +use Test::More; + +my $authordir = fakecpan_dir->child('authors/id/L/LO/LOCAL'); + +my $config = get_config(); + +my $ext = 'tar.gz'; +foreach my $test ( + [ 'MetaFile-YAML-1.1', 'Module::Faker', ['META.yml'] ], + [ 'MetaFile-JSON-1.1', 'hand', ['META.json'] ], + [ 'MetaFile-Both-1.1', 'hand', [ 'META.json', 'META.yml' ] ], + ) +{ + my ( $name, $genby, $files ) = @$test; + + my $path = "$authordir/$name.$ext"; + die 'You need to build your fakepan (with t/fakepan.t) first' + unless -e $path; + + my $release = MetaCPAN::Model::Release->new( file => $path ); + my $meta = $release->metadata; + + # some way to identify which file the meta came from + like eval { $meta->generated_by }, qr/^$genby/, + "correct meta spec version for $name"; + + # Do this after calling metadata to ensure metadata does the + # extraction. + my $extract_dir = $release->extract; + foreach my $file (@$files) { + ok( + -e $extract_dir->child( $name, $file ), + "meta file $file exists in $name" + ); + } +} + +done_testing; diff --git a/t/model/release/reverse_dependencies.t b/t/model/release/reverse_dependencies.t new file mode 100644 index 000000000..606418d8f --- /dev/null +++ b/t/model/release/reverse_dependencies.t @@ -0,0 +1,54 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server (); + +use Test::More; + +my $c = MetaCPAN::Server::; + +subtest 'distribution reverse_dependencies' => sub { + my $data = [ + sort { $a->[1] cmp $b->[1] } + map +[ @{$_}{qw(author name)} ], + @{ + $c->model('ESQuery') + ->release->reverse_dependencies('Multiple-Modules')->{data} + } + ]; + + is_deeply( + $data, + [ + [ LOCAL => 'Multiple-Modules-RDeps-2.03' ], + [ LOCAL => 'Multiple-Modules-RDeps-A-2.03' ], + ], + 'Got correct reverse dependencies for distribution.' + ); +}; + +subtest 'module reverse_dependencies' => sub { + my $data = [ + map +[ @{$_}{qw(author name)} ], + @{ + $c->model('ESQuery')->release->requires('Multiple::Modules') + ->{data} + } + ]; + + is_deeply( + $data, + [ [ LOCAL => 'Multiple-Modules-RDeps-2.03' ], ], + 'Got correct reverse dependencies for module.' + ); +}; + +subtest 'no reverse_dependencies' => sub { + my $data + = $c->model('ESQuery')->release->requires('DoesNotExist')->{data}; + + is_deeply( $data, [], 'Found no reverse dependencies for module.' ); +}; + +done_testing; diff --git a/t/model/search.t b/t/model/search.t new file mode 100644 index 000000000..d0e3262a4 --- /dev/null +++ b/t/model/search.t @@ -0,0 +1,90 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Query::Search (); +use MetaCPAN::TestServer (); +use MetaCPAN::Util qw(true false); +use Test::Deep qw( cmp_deeply ignore ); +use Test::More; + +# Just use this to get an es object. +my $server = MetaCPAN::TestServer->new; +my $search = MetaCPAN::Query::Search->new( es => $server->es_client, ); + +ok( $search, 'search' ); + +{ + my $results = $search->search_web('Fooxxxx'); + cmp_deeply( + $results, + { + results => [], + total => 0, + took => ignore(), + collapsed => true, + }, + 'no results on fake module' + ); +} + +{ + my $collapsed_search = $search->search_web('Foo'); + is( scalar @{ $collapsed_search->{results}->[0]->{hits} }, + 2, 'got results for collapsed search' ); + + ok( $collapsed_search->{collapsed}, 'results are flagged as collapsed' ); + + my $page = 1; + my $page_size = 20; + my $collapsed = 0; + + my $expanded + = $search->search_web( 'Foo', $page, $page_size, $collapsed ); + + ok( !$expanded->{collapsed}, 'results are flagged as expanded' ); + + is( $expanded->{results}->[0]->{hits}->[0]->{path}, + 'lib/Pod/Pm.pm', 'first expanded result is expected' ); + is( $expanded->{results}->[1]->{hits}->[0]->{path}, + 'lib/Pod/Pm/NoPod.pod', 'second expanded result is expected' ); +} + +{ + my $results = $search->search_web('author:Mo'); + is( @{ $results->{results} }, 5, '5 results on author search' ); +} + +{ + my $results = $search->search_web('author:Mo BadPod'); + isnt( @{ $results->{results} }, + 0, '>0 results on author search with extra' ); +} + +{ + eval { $search->search_web('usr/bin/env') }; + is( $@, '', 'search term with a / no exception' ); +} + +{ + my $long_form = $search->search_web('distribution:Pod-Pm'); + my $short_form = $search->search_web('dist:Pod-Pm'); + + cmp_deeply( + $long_form->{results}, + $short_form->{results}, + 'dist == distribution search' + ); +} + +{ + my $module = 'Binary::Data::WithPod'; + my $results = $search->search_web($module); + is( + $results->{results}->[0]->{hits}->[0]->{description}, + 'razzberry pudding', + 'description included in results' + ); +} + +done_testing(); diff --git a/t/package.t b/t/package.t new file mode 100644 index 000000000..819b5e7b8 --- /dev/null +++ b/t/package.t @@ -0,0 +1,13 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Script::Runner (); +use Test::More; + +local @ARGV = ('package'); + +# uses ./t/var/tmp/fakecpan/modules/02packages.details.txt +ok( MetaCPAN::Script::Runner->run, 'runs' ); + +done_testing(); diff --git a/t/permission.t b/t/permission.t new file mode 100644 index 000000000..403703209 --- /dev/null +++ b/t/permission.t @@ -0,0 +1,13 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Script::Runner (); +use Test::More; + +local @ARGV = ('permission'); + +# uses ./t/var/tmp/fakecpan/modules/06perms.txt +ok( MetaCPAN::Script::Runner->run, 'runs' ); + +done_testing(); diff --git a/t/pod/renderer.t b/t/pod/renderer.t new file mode 100644 index 000000000..69e1c24f3 --- /dev/null +++ b/t/pod/renderer.t @@ -0,0 +1,64 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Test::More; + +use MetaCPAN::Pod::Renderer (); + +my $factory = MetaCPAN::Pod::Renderer->new(); +my $html_renderer = $factory->html_renderer; +$html_renderer->index(0); + +my $got = q{}; + +my $source = <<'EOF'; +=pod + +=head1 DESCRIPTION +L +=cut +EOF + +{ + my $html = <<'EOF'; +

    DESCRIPTION Plack

    + +EOF + + $html_renderer->output_string( \$got ); + $html_renderer->parse_string_document($source); + is( $got, $html, 'XHTML linkifies to metacpan by default' ); +} + +{ + my $md = <<'EOF'; +# DESCRIPTION +[Plack](https://metacpan.org/pod/Plack) +EOF + + is( $factory->to_markdown($source), $md, 'markdown' ); +} + +{ + my $text = <<'EOF'; +DESCRIPTION +Plack +EOF + + is( $factory->to_text($source), $text, 'text' ); +} + +{ + my $pod = <<'EOF'; +=pod + +=head1 DESCRIPTION +L + +=cut +EOF + + is( $factory->to_pod($source), $pod, 'pod' ); +} +done_testing(); diff --git a/t/pod_coverage.t b/t/pod_coverage.t deleted file mode 100644 index 02992f11a..000000000 --- a/t/pod_coverage.t +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env perl - -use Test::More skip_all => "turn on when serious about docs"; -use Test::Pod::Coverage; -all_pod_coverage_ok({ coverage_class => 'Pod::Coverage::Moose'}); diff --git a/t/query.t b/t/query.t new file mode 100644 index 000000000..25b789053 --- /dev/null +++ b/t/query.t @@ -0,0 +1,44 @@ +use strict; +use warnings; + +use lib 't/lib'; + +use MetaCPAN::Query (); +use MetaCPAN::Server::Test (); +use Test::More; +use Scalar::Util qw( refaddr weaken ); + +my $es = MetaCPAN::Server::Test::es(); + +{ + my $query = MetaCPAN::Query->new( es => $es ); + my $release = $query->release; + + ok $release->isa('MetaCPAN::Query::Release'), + 'release object is correct class'; + is refaddr $release->query, refaddr $query, 'got same parent object'; + + weaken $release; + weaken $query; + ok !defined $query, 'parent object properly released' + or diag explain $query; + ok !defined $release, 'release object properly released' + or diag explain $release; +} + +{ + my $release = MetaCPAN::Query::Release->new( es => $es ); + my $query = $release->query; + + ok $query->isa('MetaCPAN::Query'), 'query object is correct class'; + is refaddr $query->release, refaddr $release, 'got same child object'; + + weaken $release; + weaken $query; + ok !defined $query, 'parent object properly released' + or diag explain $query; + ok !defined $release, 'release object properly released' + or diag explain $release; +} + +done_testing; diff --git a/t/query/release.t b/t/query/release.t new file mode 100644 index 000000000..fa8687177 --- /dev/null +++ b/t/query/release.t @@ -0,0 +1,33 @@ +use strict; +use warnings; + +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( query ); +use Test::More; + +my $query = query()->release; + +is( $query->_get_latest_release('DoesNotExist'), + undef, '_get_latest_release returns undef when release does not exist' ); + +is( $query->reverse_dependencies('DoesNotExist'), + undef, 'reverse_dependencies returns undef when release does not exist' ); + +is( + $query->_get_provided_modules( + { author => 'OALDERS', name => 'DOESNOTEXIST', } + ), + undef, + '_get_provided_modules returns undef when modules cannot be found' +); + +is_deeply( + $query->_get_provided_modules( + { author => 'DOY', name => 'Try-Tiny-0.21', } + ), + ['Try::Tiny'], + '_get_provided_modules returns undef when modules cannot be found' +); + +done_testing(); diff --git a/t/release/badpod.t b/t/release/badpod.t new file mode 100644 index 000000000..da9a2059c --- /dev/null +++ b/t/release/badpod.t @@ -0,0 +1,48 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( { + name => 'BadPod-0.01', + author => 'MO', + authorized => true, + first => true, + provides => [ 'BadPod', ], + main_module => 'BadPod', + modules => { + 'lib/BadPod.pm' => [ + { + name => 'BadPod', + indexed => true, + authorized => true, + version => '0.01', + version_numified => 0.01, + associated_pod => 'MO/BadPod-0.01/lib/BadPod.pm', + }, + ], + }, + extra_tests => \&test_bad_pod, +} ); + +sub test_bad_pod { + my ($self) = @_; + + my $file = $self->file_by_path('lib/BadPod.pm'); + + is $file->{sloc}, 3, 'sloc'; + is $file->{slop}, 4, 'slop'; + + is_deeply $file->{pod_lines}, [ [ 5, 7 ], ], 'no pod_lines'; + + is $file->{pod}, + + # The unknown "=head" directive will get dropped + # but the paragraph following it is valid. + q[NAME BadPod - Malformed POD There is no "more."], 'pod text'; +} + +done_testing; diff --git a/t/release/binary-data.t b/t/release/binary-data.t new file mode 100644 index 000000000..e75d8aba3 --- /dev/null +++ b/t/release/binary-data.t @@ -0,0 +1,78 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( { + name => 'Binary-Data-0.01', + author => 'BORISNAT', + authorized => true, + first => true, + provides => [ 'Binary::Data', 'Binary::Data::WithPod', ], + main_module => 'Binary::Data', + modules => { + 'lib/Binary/Data.pm' => [ + { + name => 'Binary::Data', + indexed => true, + authorized => true, + version => '0.01', + version_numified => 0.01, + associated_pod => undef, + }, + ], + 'lib/Binary/Data/WithPod.pm' => [ + { + name => 'Binary::Data::WithPod', + indexed => true, + authorized => true, + version => '0.02', + version_numified => 0.02, + associated_pod => + 'BORISNAT/Binary-Data-0.01/lib/Binary/Data/WithPod.pm', + }, + ], + }, + extra_tests => \&test_binary_data, +} ); + +sub test_binary_data { + my ($self) = @_; + + { + my $file = $self->file_by_path('lib/Binary/Data.pm'); + + is $file->{sloc}, 4, 'sloc'; + is $file->{slop}, 0, 'slop'; + + is_deeply $file->{pod_lines}, [], 'no pod_lines'; + + my $binary = $self->file_content($file); + like $binary, qr/^=[a-zA-Z]/m, 'matches loose pod pattern'; + + is $file->{pod}, q[], 'no pod text'; + } + + { + my $file = $self->file_by_path('lib/Binary/Data/WithPod.pm'); + + is $file->{sloc}, 4, 'sloc'; + is $file->{slop}, 7, 'slop'; + + is_deeply $file->{pod_lines}, [ [ 5, 5 ], [ 22, 6 ], ], 'pod_lines'; + + my $binary = $self->file_content($file); + like $binary, qr/^=F/m, 'matches simple unwanted pod pattern'; + like $binary, qr/^=buzz9\xF0\x9F\x98\x8E/m, + 'matches more complex unwanted pod pattern'; + + is $file->{pod}, + q[NAME Binary::Data::WithPod - that's it DESCRIPTION razzberry pudding], + 'pod text'; + } +} + +done_testing; diff --git a/t/release/bugs.t b/t/release/bugs.t new file mode 100644 index 000000000..a0e443e90 --- /dev/null +++ b/t/release/bugs.t @@ -0,0 +1,29 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_distribution ); +use Test::More; + +test_distribution( + 'Moose', + { + bugs => { + rt => { + source => + 'https://rt.cpan.org/Public/Dist/Display.html?Name=Moose', + new => 15, + open => 20, + stalled => 4, + patched => 0, + resolved => 122, + rejected => 23, + active => 39, + closed => 145, + }, + }, + }, + 'Test bug data for Moose dist', +); + +done_testing; diff --git a/t/release/common-files.t b/t/release/common-files.t new file mode 100644 index 000000000..de9f1806c --- /dev/null +++ b/t/release/common-files.t @@ -0,0 +1,48 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( { + name => 'Common-Files-1.1', + author => 'BORISNAT', + authorized => true, + first => true, + provides => ['Common::Files'], + modules => { + 'lib/Common/Files.pm' => [ + { + name => 'Common::Files', + indexed => true, + authorized => true, + version => '1.1', + version_numified => 1.1, + associated_pod => + 'BORISNAT/Common-Files-1.1/lib/Common/Files.pm', + }, + ], + }, + extra_tests => sub { + my ($self) = @_; + + { + my $file = $self->file_by_path('Makefile.PL'); + + ok !$file->{indexed}, 'Makefile.PL not indexed'; + ok $file->{authorized}, + 'Makefile.PL authorized, i suppose (not *un*authorized)'; + is $file->{sloc}, 1, 'sloc'; + is $file->{slop}, 3, 'slop'; + + is scalar( @{ $file->{pod_lines} } ), 1, 'one pod section'; + + is $file->{abstract}, undef, 'no abstract'; + } + + }, +} ); + +done_testing; diff --git a/t/release/devel-gofaster-0.000.t b/t/release/devel-gofaster-0.000.t new file mode 100644 index 000000000..e8769f2ad --- /dev/null +++ b/t/release/devel-gofaster-0.000.t @@ -0,0 +1,24 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( { + name => 'Devel-GoFaster-0.000', + distribution => 'Devel-GoFaster', + author => 'LOCAL', + authorized => true, + first => true, + version => '0.000', + + provides => [ 'Devel::GoFaster', ], + + # Don't test the actual numbers since we copy this out of the real + # database as a live test case. + tests => 1, +} ); + +done_testing; diff --git a/t/release/documentation-hide.t b/t/release/documentation-hide.t new file mode 100644 index 000000000..5560dab3e --- /dev/null +++ b/t/release/documentation-hide.t @@ -0,0 +1,68 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( es_result ); +use Test::More; + +my $release = es_result( + release => { + bool => { + must => [ + { term => { author => 'MO' } }, + { term => { name => 'Documentation-Hide-0.01' } }, + ], + }, + }, +); + +is( $release->{name}, 'Documentation-Hide-0.01', 'name ok' ); + +is( $release->{author}, 'MO', 'author ok' ); + +is( $release->{main_module}, 'Documentation::Hide', 'main_module ok' ); + +ok( $release->{first}, 'Release is first' ); + +{ + my @files = es_result( + file => { + bool => { + must => [ + { term => { author => $release->{author} } }, + { term => { release => $release->{name} } }, + { exists => { field => 'module.name' } }, + ], + }, + } + ); + + is( @files, 1, 'includes one file with modules' ); + + my $file = shift @files; + is( @{ $file->{module} }, 1, 'file contains one module' ); + + my ($indexed) = grep { $_->{indexed} } @{ $file->{module} }; + is( $indexed->{name}, 'Documentation::Hide', 'module name ok' ); + is( $file->{documentation}, 'Documentation::Hide', 'documentation ok' ); + + is $file->{pod}, + q[NAME Documentation::Hide::Internal - abstract], 'pod text'; +} + +{ + my @files = es_result( + file => { + bool => { + must => [ + { term => { author => $release->{author} } }, + { term => { release => $release->{name} } }, + { exists => { field => 'documentation' } } + ], + }, + } + ); + is( @files, 2, 'two files with documentation' ); +} + +done_testing; diff --git a/t/release/documentation-not-readme.t b/t/release/documentation-not-readme.t new file mode 100644 index 000000000..1d146ed7e --- /dev/null +++ b/t/release/documentation-not-readme.t @@ -0,0 +1,38 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw( true false ); +use Test::More; + +test_release( + 'RWSTAUNER/Documentation-Not-Readme-0.01', + { + first => true, + extra_tests => \&test_modules, + main_module => 'Documentation::Not::Readme', + } +); + +sub test_modules { + my ($self) = @_; + + my @files = @{ $self->module_files }; + is( @files, 1, 'includes one file with modules' ); + + my $file = shift @files; + is( @{ $file->{module} }, 1, 'file contains one module' ); + + my ($indexed) = grep { $_->{indexed} } @{ $file->{module} }; + + is( $indexed->{name}, 'Documentation::Not::Readme', 'module name' ); + is( $file->{documentation}, + 'Documentation::Not::Readme', 'documentation' ); + + is( $indexed->{associated_pod}, + 'RWSTAUNER/Documentation-Not-Readme-0.01/lib/Documentation/Not/Readme.pm' + ); +} + +done_testing; diff --git a/t/release/file-changes.t b/t/release/file-changes.t new file mode 100644 index 000000000..155adcde9 --- /dev/null +++ b/t/release/file-changes.t @@ -0,0 +1,36 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( es_result ); +use Test::More; + +my $release = es_result( + release => { + bool => { + must => [ + { term => { author => 'LOCAL' } }, + { term => { name => 'File-Changes-1.0' } }, + ], + }, + }, +); + +is( $release->{name}, 'File-Changes-1.0', 'name ok' ); +is( $release->{author}, 'LOCAL', 'author ok' ); +is( $release->{version}, '1.0', 'version ok' ); +is( $release->{main_module}, 'File::Changes', 'main_module ok' ); +is( $release->{changes_file}, 'Changes', 'changes_file ok' ); + +{ + my @files = es_result( + file => { + term => { release => 'File-Changes-1.0' } + } + ); + + my ($changes) = grep { $_->{name} eq 'Changes' } @files; + ok $changes, 'found Changes'; +} + +done_testing; diff --git a/t/release/file-duplicates.t b/t/release/file-duplicates.t new file mode 100644 index 000000000..50ca19b88 --- /dev/null +++ b/t/release/file-duplicates.t @@ -0,0 +1,68 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw( true false ); +use Test::More; + +test_release( + 'BORISNAT/File-Duplicates-1.000', + { + first => true, + main_module => 'File::Duplicates', + modules => { + 'lib/File/Duplicates.pm' => [ { + name => 'File::Duplicates', + version => '0.991', + version_numified => '0.991', + authorized => true, + indexed => true, + associated_pod => undef, + } ], + 'lib/File/lib/File/Duplicates.pm' => [ { + name => 'File::lib::File::Duplicates', + version => '0.992', + version_numified => '0.992', + authorized => true, + indexed => true, + associated_pod => undef, + } ], + 'lib/Dupe.pm' => [], + 'DupeX/Dupe.pm' => [ + { + name => 'DupeX::Dupe', + version => '0.994', + version_numified => '0.994', + authorized => true, + indexed => true, + associated_pod => undef, + }, + { + name => 'DupeX::Dupe::X', + version => '0.995', + version_numified => '0.995', + authorized => true, + indexed => true, + associated_pod => undef, + } + ], + }, + extra_tests => sub { + my $self = shift; + my $files = $self->files; + + my %dup = ( + 'lib/File/Duplicates.pm' => 4, + 'Dupe.pm' => 3, + ); + + while ( my ( $path, $count ) = each %dup ) { + is( scalar( grep { $_->{path} =~ m{\Q$path\E$} } @$files ), + $count, "multiple files match $path" ); + } + }, + } +); + +done_testing; diff --git a/t/release/ipsonar-0.29.t b/t/release/ipsonar-0.29.t new file mode 100644 index 000000000..0e314ad42 --- /dev/null +++ b/t/release/ipsonar-0.29.t @@ -0,0 +1,28 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( { + name => 'IPsonar-0.29', + distribution => 'IPsonar', + + author => 'LOCAL', + authorized => true, + first => true, + + # META file says ''. + version => '', + + # Don't test the actual numbers since we copy this out of the real + # database as a live test case. + + # This is kind of a SKIP. This may be an actual bug which we want to + # investigate later. + #tests => undef, +} ); + +done_testing; diff --git a/t/release/local-lib.t b/t/release/local-lib.t new file mode 100644 index 000000000..f5414816d --- /dev/null +++ b/t/release/local-lib.t @@ -0,0 +1,48 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( { + name => 'local-lib-0.01', + author => 'BORISNAT', + abstract => 'Legitimate module', + authorized => true, + first => true, + provides => ['local::lib'], + main_module => 'local::lib', + modules => { + 'lib/local/lib.pm' => [ + { + name => 'local::lib', + indexed => true, + authorized => true, + version => '0.01', + version_numified => 0.01, + associated_pod => 'BORISNAT/local-lib-0.01/lib/local/lib.pm', + }, + ], + }, + extra_tests => sub { + my ($self) = @_; + + { + my $file = $self->file_by_path('lib/local/lib.pm'); + + ok $file->{indexed}, 'local::lib should be indexed'; + ok $file->{authorized}, 'local::lib should be authorized'; + is $file->{sloc}, 3, 'sloc'; + is $file->{slop}, 2, 'slop'; + + is_deeply $file->{pod_lines}, [ [ 4, 3 ] ], 'pod_lines'; + + is $file->{abstract}, q[Legitimate module], 'abstract'; + } + + }, +} ); + +done_testing; diff --git a/t/release/meta-license.t b/t/release/meta-license.t new file mode 100644 index 000000000..dee378bba --- /dev/null +++ b/t/release/meta-license.t @@ -0,0 +1,26 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use Test::More; + +test_release( + 'RWSTAUNER/Meta-License-Single-1.0', + { + license => [qw( mit )], + main_module => 'Meta::License::Single', + }, + 'Meta file lists one license', +); + +test_release( + 'RWSTAUNER/Meta-License-Dual-1.0', + { + license => [qw( perl_5 bsd )], + main_module => 'Meta::License::Dual', + }, + 'Meta file lists two licenses', +); + +done_testing; diff --git a/t/release/meta-provides.t b/t/release/meta-provides.t new file mode 100644 index 000000000..4853eca50 --- /dev/null +++ b/t/release/meta-provides.t @@ -0,0 +1,94 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::ESConfig qw( es_doc_path ); +use MetaCPAN::Util qw( true false ); +use Test::More; + +test_release( + { + name => 'Meta-Provides-1.01', + author => 'RWSTAUNER', + abstract => 'has provides key in meta', + authorized => true, + first => true, + provides => [ 'Meta::Provides', ], + status => 'latest', + main_module => 'Meta::Provides', + extra_tests => sub { + + my ($self) = @_; + my $release = $self->data; + + my $res = $self->es->search( + es_doc_path('file'), + body => { + query => { + bool => { + must => [ + { + term => { 'author' => $release->{author} } + }, + { term => { 'release' => $release->{name} } }, + { term => { 'directory' => false } }, + { prefix => { 'path' => 'lib/' } }, + ], + }, + }, + }, + ); + my @files = map $_->{_source}, @{ $res->{hits}{hits} }; + + is( @files, 2, 'two files found in lib/' ); + + @files = sort { $a->{name} cmp $b->{name} } @files; + + { + my $not_indexed = shift @files; + is $not_indexed->{name}, 'NotSpecified.pm', + 'matching file name'; + is @{ $not_indexed->{module} }, 0, + 'no modules (file not parsed)'; + } + + foreach my $test ( + [ + 'Provides.pm', 'Meta::Provides', + [ { name => 'Meta::Provides', indexed => true }, ] + ], + ) + { + my ( $basename, $doc, $expmods ) = @$test; + + my $file = shift @files; + ok $file, "file present (expecting $basename)" + or next; + + is( $file->{name}, $basename, 'file name' ); + is( $file->{documentation}, $doc, 'documentation ok' ); + + is( + scalar @{ $file->{module} }, + scalar @$expmods, + 'correct number of modules' + ); + + foreach my $expmod (@$expmods) { + my $mod = shift @{ $file->{module} }; + ok $mod, "module present (expecting $expmod->{name})" + or next; + is( $mod->{name}, $expmod->{name}, 'module name ok' ); + is( $mod->{indexed}, $expmod->{indexed}, + 'module indexed (or not)' ); + } + + is( scalar @{ $file->{module} }, 0, 'all mods tested' ); + } + + }, + }, +); + +done_testing; diff --git a/t/release/moose.t b/t/release/moose.t new file mode 100644 index 000000000..f7f2bdd09 --- /dev/null +++ b/t/release/moose.t @@ -0,0 +1,108 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( es_result query ); +use MetaCPAN::Util qw( true false ); +use Test::More; + +my @moose = es_result( 'release', { term => { distribution => 'Moose' } } ); + +my $first = 0; +map { $first++ } grep { $_->{first} } @moose; + +is( $first, 1, 'only one moose is first' ); + +is( $moose[0]->{main_module}, 'Moose', 'main_module ok' ); + +is( $moose[1]->{main_module}, 'Moose', 'main_module ok' ); + +my $faq = es_result( 'file', + { match_phrase => { documentation => 'Moose::FAQ' } } ); + +ok( $faq, 'get Moose::FAQ' ); + +is( $faq->{status}, 'latest', 'is latest' ); + +ok( $faq->{indexed}, 'is indexed' ); + +ok( !$faq->{binary}, 'is not binary' ); + +my $binary = es_result( + 'file', + { + bool => { + must => [ + { term => { release => 'Moose-0.01' } }, + { term => { name => 't' } }, + ], + }, + } +); + +ok( $binary, 'get a t/ directory' ); + +ok( $binary->{binary}, 'is binary' ); + +my $ppport = es_result( 'file', + { match_phrase => { documentation => 'ppport.h' } } ); +ok( $ppport, 'get ppport.h' ); + +is( $ppport->{name}, 'ppphdoc', 'name doesn\'t contain a dot' ); + +my $signature; +($signature) = es_result( + 'file', + { + bool => { + must => [ + { term => { mime => 'text/x-script.perl' } }, + { term => { name => 'SIGNATURE' } }, + ], + }, + } +); +ok( !$signature, 'SIGNATURE is not perl code' ); + +($signature) = es_result( + 'file', + { + bool => { + must => [ + { term => { documentation => 'SIGNATURE' } }, + { term => { mime => 'text/x-script.perl' } }, + { term => { name => 'SIGNATURE' } }, + ], + }, + } +); +ok( !$signature, 'SIGNATURE is not documentation' ); + +($signature) = es_result( + 'file', + { + bool => { + must => [ + { term => { name => 'SIGNATURE' } }, + { exists => { field => 'documentation' } }, + { term => { indexed => true } }, + ], + }, + } +); +ok( !$signature, 'SIGNATURE is not pod' ); + +{ + my $files = query()->file; + my $module = $files->history( module => 'Moose' ); + my $file = $files->history( file => 'Moose', 'lib/Moose.pm' ); + + is_deeply( $module->{files}, $file->{files}, + 'history of Moose and lib/Moose.pm match' ); + is( $module->{total}, 2, 'two hits' ); + + my $pod = $files->history( documentation => 'Moose::FAQ' ); + is( $pod->{total}, 1, 'one hit' ); +} + +done_testing; diff --git a/t/release/multiple-modules.t b/t/release/multiple-modules.t new file mode 100644 index 000000000..2e24213f8 --- /dev/null +++ b/t/release/multiple-modules.t @@ -0,0 +1,146 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( es_result ); +use MetaCPAN::Util qw(true false); +use Test::More; + +my $release = es_result( + release => { + bool => { + must => [ + { term => { author => 'LOCAL' } }, + { term => { name => 'Multiple-Modules-1.01' } }, + ] + }, + } +); + +is( $release->{abstract}, 'abstract', 'abstract set from Multiple::Modules' ); + +is( $release->{name}, 'Multiple-Modules-1.01', 'name ok' ); + +is( $release->{author}, 'LOCAL', 'author ok' ); + +is( $release->{main_module}, 'Multiple::Modules', 'main_module ok' ); + +is_deeply( + [ sort @{ $release->{provides} } ], + [ + sort 'Multiple::Modules', 'Multiple::Modules::A', + 'Multiple::Modules::A2', 'Multiple::Modules::B' + ], + 'provides ok' +); + +# This test depends on files being indexed in the right order +# which depends on the mtime of the files. +ok( !$release->{first}, 'Release is not first' ); + +{ + my @files = es_result( + file => { + bool => { + must => [ + { term => { author => $release->{author} } }, + { term => { release => $release->{name} } }, + { exists => { field => 'module.name' } }, + ], + }, + }, + ); + is( @files, 3, 'includes three files with modules' ); + + @files = sort { $a->{name} cmp $b->{name} } @files; + + foreach my $test ( + [ + 'A.pm', + 'Multiple::Modules::A', + [ + { name => 'Multiple::Modules::A', indexed => true }, + { name => 'Multiple::Modules::A2', indexed => true }, + ] + ], + [ + 'B.pm', + 'Multiple::Modules::B', + [ + { name => 'Multiple::Modules::B', indexed => true }, + + #{name => 'Multiple::Modules::_B2', indexed => false }, # hidden + { name => 'Multiple::Modules::B::Secret', indexed => false }, + ] + ], + [ + 'Modules.pm', + 'Multiple::Modules', + [ { name => 'Multiple::Modules', indexed => true }, ] + ], + ) + { + my ( $basename, $doc, $expmods ) = @$test; + + my $file = shift @files; + is( $file->{name}, $basename, 'file name' ); + is( $file->{documentation}, $doc, 'documentation ok' ); + + is( + scalar @{ $file->{module} }, + scalar @$expmods, + 'correct number of modules' + ); + + foreach my $expmod (@$expmods) { + my $mod = shift @{ $file->{module} }; + if ( !$mod ) { + ok( 0, "module not found when expecting: $expmod->{name}" ); + next; + } + is( $mod->{name}, $expmod->{name}, 'module name ok' ); + is( $mod->{indexed}, $expmod->{indexed}, + 'module indexed (or not)' ); + } + + is( scalar @{ $file->{module} }, 0, 'all mods tested' ); + } +} + +$release = es_result( + release => { + bool => { + must => [ + { term => { author => 'LOCAL' } }, + { term => { name => 'Multiple-Modules-0.1' } }, + ], + }, + }, +); +ok $release, 'got older version of release'; +ok $release->{first}, 'this version was first'; + +my $file = es_result( + file => { + bool => { + must => [ + { term => { release => 'Multiple-Modules-0.1' } }, + { match_phrase => { documentation => 'Moose' } }, + ], + }, + } +); + +ok( $file, 'get Moose.pm' ); + +ok( my ($moose) = ( grep { $_->{name} eq 'Moose' } @{ $file->{module} } ), + 'find Moose module in old release' ) + or diag( Test::More::explain( { file_module => $file->{module} } ) ); + +$moose + and ok( !$moose->{authorized}, 'Moose is not authorized' ); + +$release + and ok( !$release->{authorized}, 'release is not authorized' ); + +done_testing; diff --git a/t/release/no-modules.t b/t/release/no-modules.t new file mode 100644 index 000000000..3825465c6 --- /dev/null +++ b/t/release/no-modules.t @@ -0,0 +1,29 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +# Some uploads contain no usable modules. +test_release( { + name => 'No-Modules-1.1', + author => 'BORISNAT', + authorized => true, + first => true, + + # Without modules it won't get marked as latest. + status => 'cpan', + + provides => [ + + # empty + ], + modules => { + + # empty + }, +} ); + +done_testing; diff --git a/t/release/no-packages.t b/t/release/no-packages.t new file mode 100644 index 000000000..58808d472 --- /dev/null +++ b/t/release/no-packages.t @@ -0,0 +1,29 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +# Some uploads contain no usable modules. +test_release( { + name => 'No-Packages-1.1', + author => 'BORISNAT', + authorized => true, + first => true, + + # Without modules it won't get marked as latest. + status => 'cpan', + + provides => [ + + # empty + ], + modules => { + + # empty + }, +} ); + +done_testing; diff --git a/t/release/oops-locallib.t b/t/release/oops-locallib.t new file mode 100644 index 000000000..38969b67c --- /dev/null +++ b/t/release/oops-locallib.t @@ -0,0 +1,61 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( { + name => 'Oops-LocalLib-0.01', + author => 'BORISNAT', + authorized => true, + first => true, + provides => [ 'Fruits', 'Oops::LocalLib', ], + main_module => 'Oops::LocalLib', + modules => { + 'lib/Oops/LocalLib.pm' => [ + { + name => 'Oops::LocalLib', + indexed => true, + authorized => true, + version => '0.01', + version_numified => 0.01, + associated_pod => + 'BORISNAT/Oops-LocalLib-0.01/lib/Oops/LocalLib.pm', + }, + ], + 'foreign/Fruits.pm' => [ + { + name => 'Fruits', + indexed => true, + authorized => true, + version => '1', + version_numified => 1, + associated_pod => + 'BORISNAT/Oops-LocalLib-0.01/foreign/Fruits.pm', + }, + ], + }, + extra_tests => sub { + my ($self) = @_; + + { + my $file = $self->file_by_path('local/Vegetable.pm'); + + ok !$file->{indexed}, 'file in /local/ not indexed'; + + ok $file->{authorized}, 'file in /local/ not un-authorized'; + is $file->{sloc}, 2, 'sloc'; + is $file->{slop}, 2, 'slop'; + + is_deeply $file->{pod_lines}, [ [ 4, 3 ] ], 'pod_lines'; + + is $file->{abstract}, q[should not have been included], + 'abstract'; + } + + }, +} ); + +done_testing; diff --git a/t/release/p-1.0.20.t b/t/release/p-1.0.20.t new file mode 100644 index 000000000..e36637474 --- /dev/null +++ b/t/release/p-1.0.20.t @@ -0,0 +1,41 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Ref::Util qw( is_hashref ); +use Test::More; + +use MetaCPAN::TestServer (); + +my $server = MetaCPAN::TestServer->new; +$server->index_cpantesters; + +test_release( { + name => 'P-1.0.20', + distribution => 'P', + author => 'LOCAL', + authorized => true, + first => true, + version => 'v1.0.20', + + provides => [ 'P', ], + + extra_tests => sub { + my ($self) = @_; + my $tests = $self->data->{tests}; + + # Don't test the actual numbers since we copy this out of the real + # database as a live test case. + + ok( is_hashref($tests), 'hashref of tests' ); + + ok( $tests->{pass} > 0, 'has passed tests' ); + + ok( exists( $tests->{$_} ), "has '$_' results" ) + for qw( pass fail na unknown ); + }, +} ); + +done_testing; diff --git a/t/release/packages-unclaimable.t b/t/release/packages-unclaimable.t new file mode 100644 index 000000000..a3c1eb6ca --- /dev/null +++ b/t/release/packages-unclaimable.t @@ -0,0 +1,57 @@ +use strict; +use warnings; +use lib 't/lib'; + +use List::Util qw( uniq ); +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw( true false ); +use Module::Metadata (); +use Test::More; + +test_release( + { + name => 'Packages-Unclaimable-2', + author => 'RWSTAUNER', + abstract => + 'Dist that appears to declare packages that are not allowed', + authorized => true, + first => true, + provides => [ 'Packages::Unclaimable', ], + status => 'latest', + main_module => 'Packages::Unclaimable', + modules => { + 'lib/Packages/Unclaimable.pm' => [ + { + name => 'Packages::Unclaimable', + indexed => true, + authorized => true, + version => 2, + version_numified => 2, + associated_pod => + 'RWSTAUNER/Packages-Unclaimable-2/lib/Packages/Unclaimable.pm', + }, + ], + }, + + extra_tests => sub { + my ($self) = @_; + + ok $self->data->{authorized}, 'dist is authorized'; + + my $content = $self->file_content('lib/Packages/Unclaimable.pm'); + + open my $fh, '<', \$content; + + my $mm + = Module::Metadata->new_from_handle( $fh, + 'lib/Packages/Unclaimable.pm' ); + + is_deeply [ uniq sort $mm->packages_inside ], + [ sort qw(Packages::Unclaimable main DB) ], + 'Module::Metadata finds the packages we ignore'; + }, + }, + 'Packages::Unclaimable is authorized but ignores unclaimable packages', +); + +done_testing; diff --git a/t/release/packages.t b/t/release/packages.t new file mode 100644 index 000000000..27945262c --- /dev/null +++ b/t/release/packages.t @@ -0,0 +1,66 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( + { + name => 'Packages-1.103', + author => 'RWSTAUNER', + abstract => 'Package examples', + authorized => true, + first => true, + provides => [ 'Packages', 'Packages::BOM', ], + status => 'latest', + main_module => 'Packages', + modules => { + 'lib/Packages.pm' => [ + { + name => 'Packages', + indexed => true, + authorized => true, + version => '1.103', + version_numified => 1.103, + associated_pod => + 'RWSTAUNER/Packages-1.103/lib/Packages.pm', + }, + ], + 'lib/Packages/BOM.pm' => [ + { + name => 'Packages::BOM', + indexed => true, + authorized => true, + version => 0.04, + version_numified => 0.04, + associated_pod => + 'RWSTAUNER/Packages-1.103/lib/Packages/BOM.pm', + }, + ], + }, + extra_tests => sub { + my $self = shift; + my $path = 'lib/Packages/BOM.pm'; + my $content = $self->file_content($path); + + like $content, qr/\A\xef\xbb\xbfpackage Packages::BOM;\n/, + 'Packages::BOM module starts with UTF-8 BOM'; + + my $file = $self->file_by_path($path); + + is $file->{pod}, + q[NAME Packages::BOM - package in a file with a BOM], + 'pod text'; + + is_deeply $self->file_by_path('lib/Packages/None.pm') + ->{module}, + [], + 'pm file has no packages'; + }, + }, + 'Test Packages release and its modules', +); + +done_testing; diff --git a/t/release/perl-changes-file.t b/t/release/perl-changes-file.t new file mode 100644 index 000000000..604588850 --- /dev/null +++ b/t/release/perl-changes-file.t @@ -0,0 +1,24 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( es_result ); +use Test::More; + +my $release = es_result( + release => { + bool => { + must => [ + { term => { author => 'RWSTAUNER' } }, + { term => { name => 'perl-1' } }, + ], + }, + }, +); + +is( $release->{name}, 'perl-1', 'name ok' ); +is( $release->{author}, 'RWSTAUNER', 'author ok' ); +is( $release->{version}, '1', 'version ok' ); +is( $release->{changes_file}, 'pod/perldelta.pod', 'changes_file ok' ); + +done_testing; diff --git a/t/release/pm-PL.t b/t/release/pm-PL.t new file mode 100644 index 000000000..8c17e73bc --- /dev/null +++ b/t/release/pm-PL.t @@ -0,0 +1,73 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::ESConfig qw( es_doc_path ); +use MetaCPAN::Server::Test qw( app es GET query test_psgi ); +use Test::More; + +my $query = query(); + +# Module::Faker will generate a regular pm for the main module. +is( $query->file->find_module('uncommon::sense')->{path}, + 'lib/uncommon/sense.pm', 'find main module' ); + +# This should be the .pm.PL file we specified. +ok( my $pm = $query->file->find_module('less::sense'), + 'find sense.pm.PL module' ); + +is( $pm->{name}, 'sense.pm.PL', 'name is correct' ); + +is( + $pm->{module}->[0]->{associated_pod}, + 'MO/uncommon-sense-0.01/sense.pod', + 'has associated pod file' +); + +# Ensure that $VERSION really came from file and not dist. +is( $pm->{module}->[0]->{version}, + '4.56', 'pm.PL module version is (correctly) different than main dist' ) + + # TRAVIS 5.16 + or diag($pm); + +{ + # Verify all the files we expect to be contained in the release. + my $files = es->search( + es_doc_path('file'), + body => { + query => { + term => { release => 'uncommon-sense-0.01' }, + }, + size => 20, + }, + )->{hits}->{hits}; + $files = [ map { $_->{_source} } @$files ]; + + is_deeply( + [ sort grep {/\.(pm|pod|pm\.PL)$/} map { $_->{path} } @$files ], + [ + sort qw( + lib/uncommon/sense.pm + sense.pod + sense.pm.PL + ) + ], + 'release contains expected files', + ); + + test_psgi app, sub { + my $cb = shift; + my $res = $cb->( GET '/source/MO/uncommon-sense-0.01/sense.pm.PL' ); + is $res->code, 200, '200 OK'; + chomp( my $content = $res->content ); + + is( + $content, + "#! perl-000\n\nour \$VERSION = '4.56';\n\n__DATA__\npackage less::sense;", + '.pm.PL file unmodified', + ); + }; +} + +done_testing; diff --git a/t/release/pod-examples.t b/t/release/pod-examples.t new file mode 100644 index 000000000..d8133d156 --- /dev/null +++ b/t/release/pod-examples.t @@ -0,0 +1,73 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw( true false ); +use Test::More; + +test_release( + 'RWSTAUNER/Pod-Examples-99', + { + first => true, + extra_tests => \&test_pod_examples, + main_module => 'Pod::Examples', + changes_file => 'Changes', + } +); + +sub test_pod_examples { + my ($self) = @_; + my $pod_files + = $self->filter_files( { term => { mime => 'text/x-pod' } } ); + is( @$pod_files, 1, 'includes one pod file' ); + + my @spacial = grep { $_->{documentation} eq 'Pod::Examples::Spacial' } + @$pod_files; + + is( @spacial, 1, 'parsed =head1\x20\x20NAME' ); + + is( + $spacial[0]->{pod}, + q[NAME Pod::Examples::Spacial DESCRIPTION An extra space between 'head1' and 'NAME'], + 'pod text' + ); + + my $xcodes_path = 'lib/Pod/Examples/XCodes.pm'; + my $xcodes_content = $self->file_content($xcodes_path); + my $code_re = qr!^package Pod::Examples::XCodes;!; + like( $xcodes_content, $code_re, 'file contains code' ); + + my $pod_like = sub { + my ( $type, $like, $desc ) = @_; + my $pod = $self->pod( $xcodes_path, $type ); + like $pod, $like, $desc; + unlike $pod, $code_re, "$type without code"; + }; + + # NOTE: This may change. + $pod_like->( + 'text/html', + qr{

    DESCRIPTION

    }, + 'X codes are ignored in html' + ); + + $pod_like->( + 'text/x-markdown', + qr!^# DESCRIPTION\n{2,}A doc with X codes!ms, + 'pod as markdown' + ); + + $pod_like->( + 'text/plain', qr!^DESCRIPTION\n\s*A doc with X codes!ms, + 'pod as text' + ); + + $pod_like->( + 'text/x-pod', + qr!=head1 DESCRIPTION\nX\n\nA doc with X codes!ms, + 'pod as pod (retains X code)' + ); +} + +done_testing; diff --git a/t/release/pod-pm.t b/t/release/pod-pm.t new file mode 100644 index 000000000..0730bafe3 --- /dev/null +++ b/t/release/pod-pm.t @@ -0,0 +1,21 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( query ); +use Test::More; + +my $query = query(); + +ok( my $pod_pm = $query->file->find_module('Pod::Pm'), + 'find Pod::Pm module' ); + +is( $pod_pm->{name}, 'Pm.pm', 'defined in Pm.pm' ); + +is( + $pod_pm->{module}->[0]->{associated_pod}, + 'MO/Pod-Pm-0.01/lib/Pod/Pm.pod', + 'has associated pod file' +); + +done_testing; diff --git a/t/release/pod-with-data-token.t b/t/release/pod-with-data-token.t new file mode 100644 index 000000000..5bcd5115a --- /dev/null +++ b/t/release/pod-with-data-token.t @@ -0,0 +1,69 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw( true false ); +use Test::More; + +test_release( { + name => 'Pod-With-Data-Token-0.01', + author => 'BORISNAT', + authorized => true, + first => true, + provides => [ 'Pod::With::Data::Token', ], + main_module => 'Pod::With::Data::Token', + modules => { + 'lib/Pod/With/Data/Token.pm' => [ + { + name => 'Pod::With::Data::Token', + indexed => true, + authorized => true, + version => '0.01', + version_numified => 0.01, + associated_pod => + 'BORISNAT/Pod-With-Data-Token-0.01/lib/Pod/With/Data/Token.pm', + }, + ], + }, + extra_tests => \&test_content, +} ); + +sub test_content { + my ($self) = @_; + + my $mod = $self->module_files->[0]; + + is $mod->{sloc}, 5, 'sloc'; + is $mod->{slop}, 17, 'slop'; + + is_deeply $mod->{pod_lines}, + #<<< + [ + [5, 20], + [30, 5], + [45, 3], + ], + #>>> + 'pod lines determined correctly'; + + my $content = $self->file_content($mod); + + like $content, + qr!\n=head1 SYNOPSIS\n\n\s+use warnings;\n\s+print ;\n\x20\x20__DATA__\n\s+More text\n!, + '__DATA__ token in verbatim pod in tact'; + + like $content, + qr!\n=head1 DESCRIPTION\n\ndata handle inside pod is pod but not data\n\n__DATA__\n\nsee\?\n\n=cut!, + '^__DATA__ token in pod paragraph in tact'; + + like $content, + qr!\n__DATA__\n\ndata is here\n\n__END__\n\nTHE END IS NEAR\n\n\n=pod\n\nthis is pod!, + 'actual __DATA__ and __END__ tokens in tact (with closing pod)'; + + is $mod->{pod}, + q[NAME Pod::With::Data::Token - yo SYNOPSIS use warnings; print ; __DATA__ More text DESCRIPTION data handle inside pod is pod but not data __DATA__ see? EVEN MOAR not much, though this is pod to a pod reader but DATA to perl], + 'pod text'; +} + +done_testing; diff --git a/t/release/pod-with-generator.t b/t/release/pod-with-generator.t new file mode 100644 index 000000000..74d3a89f9 --- /dev/null +++ b/t/release/pod-with-generator.t @@ -0,0 +1,61 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( { + name => 'Pod-With-Generator-1', + author => 'BORISNAT', + authorized => true, + first => true, + provides => [ 'Pod::With::Generator', ], + main_module => 'Pod::With::Generator', + modules => { + 'lib/Pod/With/Generator.pm' => [ + { + name => 'Pod::With::Generator', + indexed => true, + authorized => true, + version => '1', + version_numified => 1, + associated_pod => + 'BORISNAT/Pod-With-Generator-1/lib/Pod/With/Generator.pm', + }, + ], + }, + extra_tests => \&test_assoc_pod, +} ); + +sub test_assoc_pod { + my ($self) = @_; + + my $mod = $self->module_files->[0]; + + is $mod->{sloc}, 3, 'sloc'; + is $mod->{slop}, 5, 'slop'; + + is_deeply $mod->{pod_lines}, + [ [ 5, 9 ], ], + 'pod lines determined correctly'; + + my $pod_file = $self->file_content($mod); + my $generator = $self->file_content('config/doc_gen.pm'); + + my $real_pod = qr/this is the real one/; + like $pod_file, $real_pod, 'real pod from real file'; + unlike $generator, $real_pod, 'not in generator'; + + my $gen_text = qr/not the real abstract/; + unlike $pod_file, $gen_text, 'pod does not have generator comment'; + like $generator, $gen_text, 'generator has comment'; + + is $mod->{pod}, + q[NAME Pod::With::Generator - this pod is generated Truth but this is the real one!], + 'pod text'; + +} + +done_testing; diff --git a/t/release/prefer-meta-json.t b/t/release/prefer-meta-json.t new file mode 100644 index 000000000..56432adc1 --- /dev/null +++ b/t/release/prefer-meta-json.t @@ -0,0 +1,59 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( es_result ); +use MetaCPAN::Util qw(true false); +use Test::More; + +my $release = es_result( + release => { + bool => { + must => [ + { term => { author => 'LOCAL' } }, + { term => { name => 'Prefer-Meta-JSON-1.1' } }, + ], + }, + }, +); + +is( $release->{name}, 'Prefer-Meta-JSON-1.1', 'name ok' ); +is( $release->{distribution}, 'Prefer-Meta-JSON', 'distribution ok' ); +is( $release->{author}, 'LOCAL', 'author ok' ); +is( $release->{main_module}, 'Prefer::Meta::JSON', 'main_module ok' ); +ok( $release->{first}, 'Release is first' ); + +is( ref $release->{metadata}, 'HASH', 'comes with metadata in a hashref' ); +is( $release->{metadata}->{'meta-spec'}{version}, + 2, 'meta_spec version is 2' ); + +{ + my @files = es_result( + file => { + bool => { + must => [ + { term => { author => $release->{author} } }, + { term => { release => $release->{name} } }, + { exists => { field => 'module.name' } }, + ], + }, + }, + ); + is( @files, 1, 'includes one file with modules' ); + + my $file = shift @files; + is( $file->{documentation}, 'Prefer::Meta::JSON', 'documentation ok' ); + + my @modules = @{ $file->{module} }; + + is( scalar @modules, 2, 'file contains two modules' ); + + is( $modules[0]->{name}, 'Prefer::Meta::JSON', 'module name ok' ); + is( $modules[0]->{indexed}, true, 'main module indexed' ); + + is( $modules[1]->{name}, 'Prefer::Meta::JSON::Gremlin', + 'module name ok' ); + is( $modules[1]->{indexed}, false, 'module not indexed' ); +} + +done_testing; diff --git a/t/release/scripts.t b/t/release/scripts.t new file mode 100644 index 000000000..c6eb143de --- /dev/null +++ b/t/release/scripts.t @@ -0,0 +1,73 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( es_result ); +use MetaCPAN::Util qw(true false); +use Test::More skip_all => 'Scripting is disabled'; + +my $release = es_result( + release => { + bool => { + must => [ + { term => { author => 'MO' } }, + { term => { name => 'Scripts-0.01' } }, + ], + }, + }, +); + +is( $release->{name}, 'Scripts-0.01', 'name ok' ); + +is( $release->{author}, 'MO', 'author ok' ); + +is( $release->{version}, '0.01', 'version ok' ); + +is( $release->{main_module}, 'Scripts', 'main_module ok' ); + +{ + my @files = es_result( + file => { + bool => { + must => [ + { term => { mime => 'text/x-script.perl' } }, + { term => { distribution => 'Scripts' } }, + ], + }, + } + ); + is( @files, 4, 'four scripts found' ); + @files = sort { $a->{name} cmp $b->{name} } + grep { $_->{has_documentation} } @files; + is( @files, 2, 'two with documentation' ); + is_deeply( + [ + map { { + documentation => $_->{documentation}, + indexed => $_->{indexed}, + mime => $_->{mime}, + } } @files + ], + [ + { + documentation => 'catalyst', + indexed => true, + mime => 'text/x-script.perl' + }, + { + documentation => 'starman', + indexed => true, + mime => 'text/x-script.perl' + } + ], + 'what is to be expected' + ); + + foreach my $file (@files) { + like $file->{pod}, + qr/\ANAME (catalyst|starman) - starter\z/, + $file->{path} . ' pod text'; + } +} + +done_testing; diff --git a/t/release/some-trial.t b/t/release/some-trial.t new file mode 100644 index 000000000..51422daac --- /dev/null +++ b/t/release/some-trial.t @@ -0,0 +1,36 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( es_result ); +use Test::More; + +my $release = es_result( + release => { + bool => { + must => [ + { term => { author => 'LOCAL' } }, + { term => { name => 'Some-1.00-TRIAL' } }, + { term => { main_module => 'Some' } }, + ], + }, + }, +); + +is( $release->{name}, 'Some-1.00-TRIAL', 'name ok' ); + +is( $release->{version}, '1.00-TRIAL', 'version with trial suffix' ); + +# although the author is not listed in the 06perms file but the 02packages.details file +ok( $release->{authorized}, 'release is authorized' ); + +is_deeply $release->{tests}, + { + pass => 4, + fail => 3, + na => 2, + unknown => 1, + }, + 'cpantesters results'; + +done_testing; diff --git a/t/release/text-tabs-wrap.t b/t/release/text-tabs-wrap.t new file mode 100644 index 000000000..aa8326ea6 --- /dev/null +++ b/t/release/text-tabs-wrap.t @@ -0,0 +1,46 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_distribution test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_distribution( + 'Text-Tabs+Wrap', + { + bugs => { + rt => { + source => + 'https://rt.cpan.org/Public/Dist/Display.html?Name=Text-Tabs%2BWrap', + new => 2, + open => 0, + stalled => 0, + patched => 0, + resolved => 15, + rejected => 1, + active => 2, + closed => 16, + }, + } + }, + 'rt url is uri escaped', +); + +test_release( { + name => 'Text-Tabs+Wrap-2013.0523', + + distribution => 'Text-Tabs+Wrap', + + author => 'LOCAL', + authorized => true, + first => true, + version => '2013.0523', + + # No modules. + status => 'cpan', + + provides => [], +} ); + +done_testing; diff --git a/t/release/versions.t b/t/release/versions.t new file mode 100644 index 000000000..a08bb379b --- /dev/null +++ b/t/release/versions.t @@ -0,0 +1,31 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( query ); +use Test::More; + +my $query = query(); + +my %modules = ( + 'Versions::Our' => '1.45', + 'Versions::PkgNameVersion' => '1.67', + 'Versions::PkgNameVersionBlock' => '1.89', + 'Versions::PkgVar' => '1.23', +); + +while ( my ( $module, $version ) = each %modules ) { + + ok( my $file = $query->file->find_module($module), "find $module" ) + or next; + + ( my $path = "lib/$module.pm" ) =~ s/::/\//; + is( $file->{path}, $path, 'expected path' ); + + # Check module version (different than dist version). + is( $file->{module}->[0]->{version}, + $version, 'version parsed from file' ); + +} + +done_testing; diff --git a/t/release/weblint++-1.15.t b/t/release/weblint++-1.15.t new file mode 100644 index 000000000..7d6838fd9 --- /dev/null +++ b/t/release/weblint++-1.15.t @@ -0,0 +1,37 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( { + name => 'weblint++-1.15', + + # FIXME: Should we be stripping this? + distribution => 'weblint', + + author => 'LOCAL', + authorized => true, + first => true, + version => '1.15', + + # No modules. + status => 'cpan', + + provides => [], + + tests => 1, + + extra_tests => sub { + my ($self) = @_; + + { + is $self->data->{distribution}, 'weblint', + 'distribution matches META name, but strips out ++'; + } + }, +} ); + +done_testing; diff --git a/t/release/www-tumblr-0.t b/t/release/www-tumblr-0.t new file mode 100644 index 000000000..e092d6431 --- /dev/null +++ b/t/release/www-tumblr-0.t @@ -0,0 +1,31 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::TestHelpers qw( test_release ); +use MetaCPAN::Util qw(true false); +use Test::More; + +test_release( { + name => 'WWW-Tumblr-0', + distribution => 'WWW-Tumblr', + author => 'LOCAL', + authorized => true, + first => true, + version => '0', + + provides => [ 'WWW::Tumblr', ], + + tests => 1, + + extra_tests => sub { + my ($self) = @_; + my $tests = $self->data->{tests}; + + my $content = $self->file_content('lib/WWW/Tumblr.pm'); + like $content, qr/\$VERSION = ('?)0\1;/, 'version is zero'; + }, +} ); + +done_testing; + diff --git a/t/role_db.t b/t/role_db.t deleted file mode 100644 index 2b94692b0..000000000 --- a/t/role_db.t +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/perl - -use Data::Dump qw( dump ); -use Modern::Perl; -use Test::More tests => 3; - -require_ok( 'MetaCPAN' ); -my $cpan = MetaCPAN->new; - -isa_ok( $cpan, 'MetaCPAN' ); -ok( $cpan->schema, "got schema"); diff --git a/t/script/cover.t b/t/script/cover.t new file mode 100644 index 000000000..52bffc732 --- /dev/null +++ b/t/script/cover.t @@ -0,0 +1,21 @@ +use strict; +use warnings; + +use lib 't/lib'; + +use MetaCPAN::Script::Cover (); +use MetaCPAN::Server::Config (); +use MetaCPAN::Util qw( root_dir ); +use Test::More; +use URI (); + +my $root = root_dir(); +my $file = URI->new('t/var/cover.json')->abs("file://$root/"); + +my $config = MetaCPAN::Server::Config::config(); +$config->{cover_url} = "$file"; + +my $cover = MetaCPAN::Script::Cover->new_with_options($config); +ok $cover->run, 'runs and returns true'; + +done_testing(); diff --git a/t/script/load.t b/t/script/load.t new file mode 100644 index 000000000..546510bd4 --- /dev/null +++ b/t/script/load.t @@ -0,0 +1,43 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Test::More; + +## no perlimports +use MetaCPAN::Script::Author (); +use MetaCPAN::Script::Backpan (); +use MetaCPAN::Script::Backup (); +use MetaCPAN::Script::Check (); +use MetaCPAN::Script::Checksum (); +use MetaCPAN::Script::Contributor (); +use MetaCPAN::Script::Cover (); +use MetaCPAN::Script::CPANTesters (); +use MetaCPAN::Script::CPANTestersAPI (); +use MetaCPAN::Script::External (); +use MetaCPAN::Script::Favorite (); +use MetaCPAN::Script::First (); +use MetaCPAN::Script::Latest (); +use MetaCPAN::Script::Mapping (); +use MetaCPAN::Script::Mirrors (); +use MetaCPAN::Script::Package (); +use MetaCPAN::Script::Permission (); +use MetaCPAN::Script::Purge (); +use MetaCPAN::Script::Queue (); +use MetaCPAN::Script::Release (); +use MetaCPAN::Script::Restart (); +use MetaCPAN::Script::River (); +require MetaCPAN::Script::Role::Contributor; +require MetaCPAN::Script::Role::External::Cygwin; +require MetaCPAN::Script::Role::External::Debian; +use MetaCPAN::Script::Runner (); +use MetaCPAN::Script::Session (); +use MetaCPAN::Script::Snapshot (); +use MetaCPAN::Script::Suggest (); +use MetaCPAN::Script::Tickets (); +use MetaCPAN::Script::Watcher (); +## use perlimports + +pass 'all loaded Ok'; + +done_testing(); diff --git a/t/script/queue.t b/t/script/queue.t new file mode 100644 index 000000000..7b1108ea1 --- /dev/null +++ b/t/script/queue.t @@ -0,0 +1,17 @@ +use strict; +use warnings; + +use MetaCPAN::Script::Queue (); +use MetaCPAN::Server::Config (); +use Test::More; + +my $config = MetaCPAN::Server::Config::config(); +local @ARGV = ( '--dir', $config->{cpan} ); + +my $queue = MetaCPAN::Script::Queue->new_with_options($config); +$queue->run; + +is( $queue->stats->{inactive_jobs}, + 54, '54 files added to queue for indexing' ); + +done_testing(); diff --git a/t/script/river.t b/t/script/river.t new file mode 100644 index 000000000..bdfc0f949 --- /dev/null +++ b/t/script/river.t @@ -0,0 +1,61 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Script::River (); +use MetaCPAN::Server::Test qw( app GET ); +use MetaCPAN::TestHelpers qw( decode_json_ok ); +use MetaCPAN::Util qw( root_dir ); +use Plack::Test (); +use Test::More; +use URI (); + +my $config = MetaCPAN::Server::Config::config(); + +# local json file with structure from https://github.com/metacpan/metacpan-api/issues/460 +my $root = root_dir(); +my $file = URI->new('t/var/river.json')->abs("file://$root/"); +$config->{'river_url'} = "$file"; + +my $river = MetaCPAN::Script::River->new_with_options($config); +ok $river->run, 'runs and returns true'; + +my %expect = ( + 'System-Command' => { + total => 92, + immediate => 4, + bucket => 2, + }, + 'Text-Markdown' => { + total => 92, + immediate => 56, + bucket => 2, + } +); + +my $test = Plack::Test->create( app() ); + +for my $dist ( keys %expect ) { + my $expected = $expect{$dist}; + subtest "Check $dist" => sub { + my $url = "/distribution/$dist"; + my $res = $test->request( GET $url ); + diag "GET $url"; + + # TRAVIS 5.18 + is( $res->code, 200, "code 200" ); + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + my $json = decode_json_ok($res); + + # TRAVIS 5.18 + is_deeply( $json->{river}, $expected, + "$dist river summary roundtrip" ); + }; + last; +} + +done_testing(); diff --git a/t/script/runner.t b/t/script/runner.t new file mode 100644 index 000000000..e223975bb --- /dev/null +++ b/t/script/runner.t @@ -0,0 +1,62 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Script::Runner (); +use Module::Pluggable search_dirs => ['t/lib']; +use Test::More; + +subtest 'runner succeeds' => sub { + local @ARGV = ('mockerror'); + + ok( MetaCPAN::Script::Runner::run, 'succeeds' ); + + is( $MetaCPAN::Script::Runner::EXIT_CODE, 0, "Exit Code '0' - No Error" ); +}; + +subtest 'runner fails' => sub { + local @ARGV + = ( 'mockerror', '--error', 11, '--message', 'mock error message' ); + + ok( !MetaCPAN::Script::Runner::run, 'fails as expected' ); + + is( $MetaCPAN::Script::Runner::EXIT_CODE, + 11, "Exit Code '11' as expected" ); +}; + +# Disable for the time being. There is a better way to check exit codes. +# +# subtest 'runner dies' => sub { +# local @ARGV = ( 'mockerror', '--die', '--message', 'mock die message' ); +# +# ok( !MetaCPAN::Script::Runner::run, 'fails as expected' ); +# +# is( $MetaCPAN::Script::Runner::EXIT_CODE, 1, +# "Exit Code '1' as expected" ); +# }; + +subtest 'runner exits with error' => sub { + local @ARGV = ( + 'mockerror', '--handle_error', '--error', 17, '--message', + 'mock handled error message' + ); + + ok( !MetaCPAN::Script::Runner::run, 'fails as expected' ); + + is( $MetaCPAN::Script::Runner::EXIT_CODE, + 17, "Exit Code '17' as expected" ); +}; + +subtest 'runner throws exception' => sub { + local @ARGV = ( + 'mockerror', '--exception', '--error', 19, '--message', + 'mock exception message' + ); + + ok( !MetaCPAN::Script::Runner::run, 'fails as expected' ); + + is( $MetaCPAN::Script::Runner::EXIT_CODE, + 19, "Exit Code '19' as expected" ); +}; + +done_testing(); diff --git a/t/server/controller/author.t b/t/server/controller/author.t new file mode 100644 index 000000000..5abe58e00 --- /dev/null +++ b/t/server/controller/author.t @@ -0,0 +1,112 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app es GET POST test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok test_cache_headers ); +use Test::More; + +my %tests = ( + '/author' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + '/author/DOESNEXIST' => { + code => 404, + cache_control => undef, + surrogate_key => + 'author=DOESNEXIST content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + '/author/MO' => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=MO content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + '/author/_mapping' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, +); + +test_psgi app, sub { + my $cb = shift; + while ( my ( $k, $v ) = each %tests ) { + ok( my $res = $cb->( GET $k ), "GET $k" ); + is( $res->code, $v->{code}, "code " . $v->{code} ); + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + + test_cache_headers( $res, $v ); + + my $json = decode_json_ok($res); + ok( $json->{pauseid} eq 'MO', 'pauseid is MO' ) + if ( $k eq '/author/MO' ); + + if ( $k eq '/author/_mapping' ) { + my ($index) = keys %{$json}; + my $check_mappings = $json->{$index}{mappings}; + if ( es->api_version le '5_0' ) { + $check_mappings = $check_mappings->{author}; + } + + ok( $check_mappings->{properties}{name}, '_mapping' ); + } + } + + ok( my $res = $cb->( GET '/author/MO?callback=jsonp' ), 'GET jsonp' ); + is( + $res->header('content-type'), + 'text/javascript; charset=UTF-8', + 'Content-type' + ); + like( $res->content, qr/^\/\*\*\/jsonp\(.*\);$/ms, + 'includes jsonp callback' ); + + ok( + $res = $cb->( + POST '/author/_search', + + #'Content-type' => 'application/json', + Content => '{"query":{"match_all":{}},"size":0}' + ), + 'POST _search' + ); + + ok( $res = $cb->( GET '/author/DOY' ), 'GET /author/DOY' ); + + my $doy = decode_json_ok($res); + + is( $doy->{pauseid}, 'DOY', 'found author' ); + + my $links = $doy->{links}; + is_deeply( + [ sort keys %{$links} ], + [ + qw< backpan_directory cpan_directory cpantesters_matrix cpantesters_reports cpants metacpan_explorer repology> + ], + 'links has the correct keys' + ); + + { + ok( my $res = $cb->( GET '/author/_search?q=*&size=99999' ), + 'GET size=99999' ); + is( $res->code, 416, 'bad request' ); + } + +}; + +done_testing; diff --git a/t/server/controller/bad_request.t b/t/server/controller/bad_request.t new file mode 100644 index 000000000..6f536a494 --- /dev/null +++ b/t/server/controller/bad_request.t @@ -0,0 +1,75 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Cpanel::JSON::XS qw( decode_json ); +use MetaCPAN::Server (); +use Plack::Test (); +use Ref::Util qw( is_hashref ); +use Test::More; + +my $app = MetaCPAN::Server->new->to_app(); +my $test = Plack::Test->create($app); + +my $sbigqueryjson = q({ + "query": { + "bool": { + "must": [ + { "query_string": { + "query": "cpanfile" + } }, + { "term": { "status": "latest" } } + ] + } + }, + "fields": [ "distribution", "release", "module.name", "name", "path", "download_url" ], + "size": "5001" +}); + +my @tests = ( + [ + 'broken body content', + 400, + q[ { "query : { } } ], + 'error', + 'unexpected end of string', + ], + [ + 'plain text query', + 400, 'some content as invalid JSON', + 'error', 'malformed JSON string', + ], + [ + 'big result query', 416, $sbigqueryjson, 'message', 'exceeds maximum', + ], +); + +for (@tests) { + my ( $title, $code, $query, $field, $check ) = @$_; + + subtest $title => sub { + for my $type (qw( release file )) { + my $url = "/$type/_search"; + my $request = HTTP::Request->new( 'POST', $url, [], $query ); + + subtest "check with '$type' controller" => sub { + my $res = $test->request($request); + + ok( $res, "GET $url" ); + is( $res->code, $code, "code [$code] as expected" ); + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + my $content = eval { decode_json( $res->content ) }; + ok( is_hashref($content), 'response is a JSON object' ); + ok $content->{$field}, "response includes field '$field'"; + ok $content->{$field} =~ /$check/i, + 'response error message as expected'; + }; + } + }; +} + +done_testing; diff --git a/t/server/controller/changes.t b/t/server/controller/changes.t new file mode 100644 index 000000000..d64cbb3ad --- /dev/null +++ b/t/server/controller/changes.t @@ -0,0 +1,143 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok test_cache_headers ); +use Test::More; + +my $LOCAL_default_headers = { + cache_control => undef, + surrogate_key => + 'author=LOCAL content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', +}; + +my $RWSTAUNER_default_headers = { + cache_control => undef, + surrogate_key => + 'author=RWSTAUNER content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', +}; + +my @tests = ( + [ + '/changes/File-Changes' => 200, + Changes => qr/^Revision history for Changes\n\n2\.0.+1\.0.+/sm, + $LOCAL_default_headers, + ], + [ + '/changes/LOCAL/File-Changes-2.0' => 200, + Changes => qr/^Revision history for Changes\n\n2\.0.+1\.0.+/sm, + $LOCAL_default_headers, + ], + [ + '/changes/LOCAL/File-Changes-1.0' => 200, + Changes => qr/^Revision history for Changes\n\n1\.0.+/sm, + $LOCAL_default_headers, + ], + [ + '/changes/File-Changes-News' => 200, + NEWS => qr/^F\nR\nE\nE\nF\nO\nR\nM\n/, + $LOCAL_default_headers, + ], + [ + '/changes/LOCAL/File-Changes-News-11.22' => 200, + NEWS => qr/^F\nR\nE\nE\nF\nO\nR\nM\n/, + $LOCAL_default_headers, + ], + [ + '/changes/NOEXISTY' => 404, + '', + { + cache_control => undef, + surrogate_key => + 'author=NOEXISTY content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + } + ], + [ + '/changes/NOAUTHOR/NODIST' => 404, + '', + { + cache_control => undef, + surrogate_key => + 'author=NOAUTOR content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + } + ], + + # Don't search for all files. + [ '/changes' => 404, '', $LOCAL_default_headers ], + + # NOTE: We need to use author/release because in these tests + # 'perl' doesn't get flagged as latest. + [ + '/changes/RWSTAUNER/perl-1' => 200, + 'perldelta.pod' => + qr/^=head1 NAME\n\nperldelta - changes for perl\n\n/m, + $RWSTAUNER_default_headers, + ], + [ + '/changes/File-Changes-UTF8' => 200, + 'Changes' => qr/^ - 23E7 \x{23E7} ELECTRICAL INTERSECTION/m, + $RWSTAUNER_default_headers, + ], + [ + '/changes/File-Changes-Latin1' => 200, + 'Changes' => qr/^ - \244 CURRENCY SIGN/m, + $RWSTAUNER_default_headers, + ], +); + +test_psgi app, sub { + my $cb = shift; + for my $test (@tests) { + my ( $path, $code, $name, $content, $headers ) = @{$test}; + + my $res = get_ok( $cb, $path, $code ); + my $json = decode_json_ok($res); + + test_cache_headers( $res, $headers ); + + next unless $res->code == 200; + + is $json->{name}, $name, 'change log has expected name'; + like $json->{content}, $content, 'file content'; + + my @fields = qw(release name content); + $res = get_ok( $cb, "$path?fields=" . join( q[,], @fields ), 200 ); + $json = decode_json_ok($res); + + is_deeply [ sort keys %$json ], [ sort @fields ], + 'only requested fields'; + like $json->{content}, $content, 'content as expected'; + is $json->{name}, $name, 'name as expected'; + + { + my $suffix = 'v?[0-9.]+'; # wrong, but good enough + my $prefix = ( $path =~ m{([^/]+?)(-$suffix)?$} )[0]; + like $json->{release}, qr/^\Q$prefix\E-$suffix$/, + 'release as expected'; + } + } +}; + +done_testing; + +sub get_ok { + my ( $cb, $path, $code ) = @_; + + ok( my $res = $cb->( GET $path ), "GET $path" ); + is( $res->code, $code, "code $code" ); + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + return $res; +} diff --git a/t/server/controller/contributor.t b/t/server/controller/contributor.t new file mode 100644 index 000000000..1afa1cc54 --- /dev/null +++ b/t/server/controller/contributor.t @@ -0,0 +1,62 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Cpanel::JSON::XS qw( decode_json ); +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestServer (); +use Test::More; + +my $server = MetaCPAN::TestServer->new; + +test_psgi app, sub { + my $cb = shift; + + { + my $release_name = 'DOY/Try-Tiny-0.22'; + ok( my $res = $cb->( GET "/contributor/$release_name" ), + "GET contributors for $release_name" ); + is( $res->code, 200, '200 OK' ); + + is_deeply( + decode_json( $res->content ), + { + contributors => [ + { + "release_name" => "Try-Tiny-0.22", + "pauseid" => "CEBJYRE", + "distribution" => "Try-Tiny", + "release_author" => "DOY" + }, + { + "distribution" => "Try-Tiny", + "release_author" => "DOY", + "pauseid" => "JAWNSY", + "release_name" => "Try-Tiny-0.22" + }, + { + "release_name" => "Try-Tiny-0.22", + "pauseid" => "ETHER", + "distribution" => "Try-Tiny", + "release_author" => "DOY" + }, + { + "release_author" => "DOY", + "distribution" => "Try-Tiny", + "pauseid" => "RIBASUSHI", + "release_name" => "Try-Tiny-0.22" + }, + { + "pauseid" => "RJBS", + "release_author" => "DOY", + "distribution" => "Try-Tiny", + "release_name" => "Try-Tiny-0.22" + } + ] + }, + 'Has the correct contributors info' + ); + } +}; + +done_testing; diff --git a/t/server/controller/cover.t b/t/server/controller/cover.t new file mode 100644 index 000000000..704f8d68b --- /dev/null +++ b/t/server/controller/cover.t @@ -0,0 +1,61 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET ); +use MetaCPAN::TestHelpers qw( decode_json_ok ); +use Test::More; + +my %expect = ( + 'MetaFile-Both-1.1' => { + criteria => { + branch => '12.50', + condition => '0.00', + statement => '63.64', + subroutine => '71.43', + total => '46.51', + }, + distribution => 'MetaFile-Both', + release => 'MetaFile-Both-1.1', + url => 'http://cpancover.com/latest/MetaFile-Both-1.1/index.html', + version => '1.1', + }, + 'Pod-With-Generator-1' => { + criteria => { + branch => '78.95', + condition => '46.67', + statement => '95.06', + subroutine => '100.00', + total => '86.58', + }, + distribution => 'Pod-With-Generator', + release => 'Pod-With-Generator-1', + url => 'http://cpancover.com/latest/Pod-With-Generator-1/index.html', + version => '1', + }, +); + +my $test = Plack::Test->create( app() ); + +for my $release ( keys %expect ) { + my $expected = $expect{$release}; + subtest "Check $release" => sub { + my $url = "/cover/$release"; + my $res = $test->request( GET $url ); + diag "GET $url"; + + # TRAVIS 5.18 + is( $res->code, 200, "code 200" ); + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + my $json = decode_json_ok($res); + + # TRAVIS 5.18 + is_deeply( $json, $expected, "$release cover summary roundtrip" ); + }; +} + +done_testing(); diff --git a/t/server/controller/diff.t b/t/server/controller/diff.t new file mode 100644 index 000000000..d6dc18e89 --- /dev/null +++ b/t/server/controller/diff.t @@ -0,0 +1,234 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( + decode_json_ok + multiline_diag + test_cache_headers +); +use Test::More; + +{ + no warnings 'redefine'; + + sub get_ok { + my ( $cb, $url, $desc, $headers ) = @_; + ok( my $res = $cb->( GET $url ), $desc || "GET $url" ); + is( $res->code, 200, 'code 200' ); + + test_cache_headers( $res, $headers ); + + return $res; + } +} + +sub get_json_ok { + return decode_json_ok( get_ok(@_) ); +} + +test_psgi app, sub { + my $cb = shift; + + my $dist_url = '/diff/release/Moose'; + my $json = get_json_ok( + $cb, + $dist_url, + 'GET /diff/dist', + { + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + } + ); + + diffed_file_like( $json, 'DOY/Moose-0.01', 'DOY/Moose-0.02', + 'Changes' => + qq|-2012-01-01 0.01 First release - codename 'M\xc3\xbcnchen'\n|, + ); + + my $plain = plain_text_diff_ok( + $cb, + plain_text_url($dist_url), + 'plain text dist diff', + ); + + like( + $plain, + + # Encoding will be mangled, so relax the test slightly. + qr|^-2012-01-01 0.01 First release - codename '.+?'$|m, + 'found expected diff test on whole line' + ); + + my $release_url = '/diff/release/DOY/Moose-0.01/DOY/Moose-0.02/'; + my $json2 = get_json_ok( + $cb, + $release_url, + 'GET /diff/author/release/author/release', + { + cache_control => undef, + surrogate_key => + 'author=DOY content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + } + ); + + my $plain2 = plain_text_diff_ok( + $cb, + plain_text_url($release_url), + 'plain text release diff', + ); + + is_deeply( $json, $json2, 'json matches with previous run' ); + is $plain, $plain2, 'plain text diffs are the same'; + + my $file_url + = '/diff/file/8yTixXQGpkbPsMBXKvDoJV4Qkg8/dPgxn7qq0wm1l_UO1aIMyQWFJPw'; + $json = get_json_ok( + $cb, + $file_url, + 'GET diff Moose.pm', + { + cache_control => undef, + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + } + ); + + $plain = plain_text_diff_ok( + $cb, + plain_text_url($file_url), + 'plain text file url' + ); + + diffed_file_like( + $json, + 'DOY/Moose-0.01', + 'DOY/Moose-0.02', + 'lib/Moose.pm' => < 'file' }, + ); + + foreach my $chars ( [ q[-], 1 ], [ q[+], 2 ] ) { + like $plain, + qr/^\Q$chars->[0]\Eour \$VERSION = '0.0\Q$chars->[1]\E';$/m, + 'diff has insert and delete on whole lines'; + } + + diff_releases( + $cb, + 'RWSTAUNER/Encoding-1.0', + 'RWSTAUNER/Encoding-1.1', + { + 'lib/Encoding/CP1252.pm' => < } ++sub bullet { qq<\xe2\x80\xa2-\xc3\xb7> } +DIFF + }, + ); + + diff_releases( + $cb, + 'RWSTAUNER/Encoding-1.1', + 'RWSTAUNER/Encoding-1.2', + { + 'lib/Encoding/UTF8.pm' => <; ++my \$heart = qq<\342\231\245>; +DIFF + }, + ); +}; + +done_testing; + +sub diff_releases { + my ( $cb, $r1, $r2, $files ) = @_; + my $url = "/diff/release/$r1/$r2"; + subtest $url, sub { + do_release_diff( $cb, $url, $r1, $r2, $files ); + }; +} + +sub do_release_diff { + my ( $cb, $url, $r1, $r2, $files ) = @_; + $files ||= {}; + + my $json = get_json_ok( $cb, $url ); + + while ( my ( $file, $re ) = each %$files ) { + diffed_file_like( $json, $r1, $r2, $file, $re ); + } + + return $json; +} + +sub diffed_file_like { + my ( $json, $r1, $r2, $file, $like, $opts ) = @_; + $opts ||= {}; + $opts->{type} ||= 'dir'; + + my %pairs = ( source => $r1, target => $r2 ); + while ( my ( $which, $dir ) = each %pairs ) { + + # For release (dir) diff, source/target will be release (dir). + # For file diff they will start with dir but have the file on the end. + is $json->{$which}, + ( $dir . ( $opts->{type} eq 'file' ? "/$file" : q[] ) ), + "diff $which"; + } + + my $found = 0; + foreach my $stat ( @{ $json->{statistics} } ) { + my $diff = $stat->{diff}; + + # do byte comparison for these tests + $diff = Encode::encode_utf8($diff) + if utf8::is_utf8($diff); + + if ( diffed_file_name_eq( $stat->{source}, $r1, $file ) + || diffed_file_name_eq( $stat->{target}, $r2, $file ) ) + { + ++$found; + my ( $cmp, $desc ) + = ref($like) eq 'RegExp' + ? ( $diff =~ $like, "$file diff matched" ) + : ( + index( $diff, $like ) >= 0, + "substring found in $file diff" + ); + ok( $cmp, $desc ) + or multiline_diag( substr => $like, diff => $diff ); + } + } + + is $found, 1, "found one patch for $file"; +} + +sub diffed_file_name_eq { + my ( $str, $dir, $file ) = @_; + my ( $root, $dist ) = split /\//, $dir; + + # $dist x 2: once for the extraction dir, + # once b/c Module::Faker makes good tars that have a root dir + return $str eq qq{$root/$dist/$dist/$file}; +} + +sub plain_text_url { + return $_[0] . '?content-type=text/plain'; +} + +sub plain_text_diff_ok { + my $plain = get_ok(@_)->content; + like $plain, qr|\Adiff|, 'plain text format is not json'; + return $plain; +} diff --git a/t/server/controller/distribution.t b/t/server/controller/distribution.t new file mode 100644 index 000000000..491cf62b0 --- /dev/null +++ b/t/server/controller/distribution.t @@ -0,0 +1,67 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok test_cache_headers ); +use MetaCPAN::Util qw( hit_total ); +use Test::More; + +my @tests = ( + [ + '/distribution' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + } + ], + [ + '/distribution/DOESNEXIST' => { + code => 404, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + } + ], + [ + '/distribution/Moose' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + } + ], +); + +test_psgi app, sub { + my $cb = shift; + for my $test (@tests) { + my ( $k, $v ) = @{$test}; + ok( my $res = $cb->( GET $k ), "GET $k" ); + + # TRAVIS 5.18 + is( $res->code, $v->{code}, "code " . $v->{code} ); + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + test_cache_headers( $res, $v ); + + my $json = decode_json_ok($res); + if ( $k eq '/distribution' ) { + ok( hit_total($json), 'got total count' ); + } + elsif ( $v eq 200 ) { + + # TRAVIS 5.18 + ok( $json->{name} eq 'Moose', 'Moose' ); + } + } +}; + +done_testing; diff --git a/t/server/controller/download_url.t b/t/server/controller/download_url.t new file mode 100644 index 000000000..2157a0b69 --- /dev/null +++ b/t/server/controller/download_url.t @@ -0,0 +1,109 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Cpanel::JSON::XS (); +use HTTP::Request::Common qw( GET ); +use MetaCPAN::Server (); +use MetaCPAN::TestHelpers qw( test_cache_headers ); +use Plack::Test (); +use Ref::Util qw( is_hashref ); +use Test::More; + +my $app = MetaCPAN::Server->new->to_app(); +my $test = Plack::Test->create($app); + +my @tests = ( + [ 'no parameters', '/download_url/Moose', 'latest', '0.02', ], + [ + 'version == (1)', '/download_url/Moose?version===0.01', + 'cpan', '0.01' + ], + [ + 'version == (2)', '/download_url/Moose?version===0.02', + 'latest', '0.02' + ], + [ + 'version != (1)', '/download_url/Moose?version=!=0.01', + 'latest', '0.02' + ], + [ + 'version != (2)', '/download_url/Moose?version=!=0.02', + 'cpan', '0.01' + ], + [ + 'version <= (1)', '/download_url/Moose?version=<=0.01', + 'cpan', '0.01' + ], + [ + 'version <= (2)', '/download_url/Moose?version=<=0.02', + 'latest', '0.02' + ], + [ 'version >=', '/download_url/Moose?version=>=0.01', 'latest', '0.02' ], + [ + 'range >, <', + '/download_url/Try::Tiny?version=>0.21,<0.27', + 'cpan', + '0.24', + '1a12a51cfeb7e2c301e4ae093c7ecdfb', + '9b7a1af24c0256973d175369ebbdc25ec01e2452a97f2d3ab61481c826f38d81', + ], + [ + 'range >, <, !', + '/download_url/Try::Tiny?version=>0.21,<0.27,!=0.24', + 'cpan', '0.23' + ], + [ + 'range >, <; dev', + '/download_url/Try::Tiny?version=>0.21,<0.27&dev=1', + 'cpan', '0.26' + ], + [ + 'range >, <, !; dev', + '/download_url/Try::Tiny?version=>0.21,<0.27,!=0.26&dev=1', + 'cpan', '0.25' + ], +); + +for (@tests) { + my ( $title, $url, $status, $version, $checksum_md5, $checksum_sha256 ) + = @$_; + + subtest $title => sub { + my $res = $test->request( GET $url ); + ok( $res, "GET $url" ); + is( $res->code, 200, "code 200" ); + + test_cache_headers( + $res, + { + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + ); + + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + my $content = Cpanel::JSON::XS::decode_json $res->content; + ok( is_hashref($content), 'content is a JSON object' ); + is( $content->{status}, $status, "correct status ($status)" ); + is( $content->{version}, $version, "correct version ($version)" ); + + if ($checksum_md5) { + is( $content->{checksum_md5}, + $checksum_md5, "correct checksum_md5 ($checksum_md5)" ); + } + if ($checksum_sha256) { + is( $content->{checksum_sha256}, + $checksum_sha256, + "correct checksum_sha256 ($checksum_sha256)" ); + } + }; +} + +done_testing; diff --git a/t/server/controller/file.t b/t/server/controller/file.t new file mode 100644 index 000000000..1deb132e2 --- /dev/null +++ b/t/server/controller/file.t @@ -0,0 +1,73 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok test_cache_headers ); +use MetaCPAN::Util qw( hit_total ); +use Test::More; + +my %tests = ( + '/file' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + '/file/8yTixXQGpkbPsMBXKvDoJV4Qkg8' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + '/file/DOESNEXIST' => { + code => 404, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + '/file/DOES/Not/Exist.pm' => { + code => 404, + cache_control => undef, + surrogate_key => + 'author=DOES content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + '/file/DOY/Moose-0.01/lib/Moose.pm' => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=DOY content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, +); + +test_psgi app, sub { + my $cb = shift; + while ( my ( $k, $v ) = each %tests ) { + ok( my $res = $cb->( GET $k ), "GET $k" ); + is( $res->code, $v->{code}, "code " . $v->{code} ); + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + + test_cache_headers( $res, $v ); + + my $json = decode_json_ok($res); + if ( $k eq '/file' ) { + ok( hit_total($json), 'got total count' ); + } + elsif ( $v eq 200 ) { + ok( $json->{name} eq 'Moose.pm', 'Moose.pm' ); + } + } +}; + +done_testing; diff --git a/t/server/controller/login/pause.t b/t/server/controller/login/pause.t new file mode 100644 index 000000000..b83a42e38 --- /dev/null +++ b/t/server/controller/login/pause.t @@ -0,0 +1,75 @@ +use strict; +use warnings; +use lib 't/lib'; +use utf8; + +use Encode qw( encode FB_CROAK is_utf8 LEAVE_SRC ); +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok ); +use Test::More; + +BEGIN { $ENV{EMAIL_SENDER_TRANSPORT} = 'Test' } + +test_psgi app, sub { + my $cb = shift; + + test_pause_auth( $cb, 'RWSTAUNER', 'Trouble Maker' ); + test_pause_auth( $cb, 'NEVERHEARDOFHIM', 'Who?', fail => 1 ); + test_pause_auth( $cb, 'BORISNAT', 'Лось и Белка' ); +}; + +done_testing; + +sub test_pause_auth { + my ( $cb, $pause_id, $full_name, %args ) = @_; + + subtest _u("PAUSE login email for $pause_id $full_name") => sub { + my $req = GET("/login/pause?id=$pause_id"); + my $res = $cb->($req); + my $delivery + = Email::Sender::Simple->default_transport->shift_deliveries; + my $email = $delivery->{email}; + + my $body = decode_json_ok($res); + is $res->code, 200, 'GET ok'; + + if ( $args{fail} ) { + is( $body->{error}, 'author_not_found', + 'recognize nonexistent author' ); + return; + } + + is $body->{success}, 'mail_sent', 'success'; + ok $email, 'sent email' + or die explain $res; + + ok !is_utf8( $email->get_body ), + 'body is octets (no wide characters)'; + + # Thanks ANDK! + is $email->get_header('MIME-Version'), '1.0', 'valid MIME-Version'; + + is $email->get_header('to'), "\L$pause_id\@cpan.org", + 'To: cpan address'; + like $email->get_header('subject'), qr/\bmetacpan\s.+\sPAUSE\b/i, + 'subject mentions metacpan and pause'; + + like $email->get_body, qr/Hi \Q${\ _u($full_name) }\E,/, + 'email body has user\'s name'; + + like $email->get_body, qr/verify.+\sPAUSE\b/, + 'email body mentions verifying pause account'; + + like $email->get_body, + qr!\shttp://${\ $req->uri->host }${\ $req->uri->path }\?code=\S!m, + 'email body contains uri with code'; + + # TODO: figure out what the oauth redirect is supposed to do and test it + }; +} + +sub _u { + my $s = $_[0]; + ## no critic (Bitwise) + return is_utf8($s) ? encode( 'UTF-8', $s, FB_CROAK | LEAVE_SRC ) : $s; +} diff --git a/t/server/controller/mirror.t b/t/server/controller/mirror.t new file mode 100644 index 000000000..76e8e00e8 --- /dev/null +++ b/t/server/controller/mirror.t @@ -0,0 +1,50 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok test_cache_headers ); +use Test::More; + +my %tests = ( + '/mirror' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + '/mirror/DOESNEXIST' => { + code => 404, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + '/mirror/search?q=*' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, +); + +test_psgi app, sub { + my $cb = shift; + for my $k ( sort keys %tests ) { + my $v = $tests{$k}; + ok( my $res = $cb->( GET $k ), "GET $k" ); + is( $res->code, $v->{code}, "code " . $v->{code} ); + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + test_cache_headers( $res, $v ); + + decode_json_ok($res); + } +}; + +done_testing; diff --git a/t/server/controller/module.t b/t/server/controller/module.t new file mode 100644 index 000000000..c5af60c45 --- /dev/null +++ b/t/server/controller/module.t @@ -0,0 +1,90 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok test_cache_headers ); +use MetaCPAN::Util qw( hit_total ); +use Test::More; + +my %tests = ( + + '/module' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + '/module/DOY/Moose-0.01/lib/Moose.pm' => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=DOY content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + '/module/Moose' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + '/module/Moose?fields=documentation,name' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + + '/module/DOESNEXIST' => { + code => 404, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + '/module/DOES/Not/Exist.pm' => { + code => 404, + cache_control => undef, + surrogate_key => + 'author=DOES content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + +); + +test_psgi app, sub { + my $cb = shift; + while ( my ( $k, $v ) = each %tests ) { + ok( my $res = $cb->( GET $k ), "GET $k" ); + is( $res->code, $v->{code}, "code " . $v->{code} ); + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + + test_cache_headers( $res, $v ); + + my $json = decode_json_ok($res); + if ( $k eq '/module' ) { + ok( hit_total($json), 'got total count' ); + } + elsif ( $k =~ /fields/ ) { + is_deeply( + $json, + { documentation => 'Moose', name => 'Moose.pm' }, + 'controller proxies field query parameter to ES' + ); + } + elsif ( $v eq 200 ) { + ok( $json->{name} eq 'Moose.pm', 'Moose.pm' ); + } + } +}; + +done_testing; diff --git a/t/server/controller/package.t b/t/server/controller/package.t new file mode 100644 index 000000000..a36163c01 --- /dev/null +++ b/t/server/controller/package.t @@ -0,0 +1,51 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Cpanel::JSON::XS qw( decode_json ); +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestServer (); +use Test::More; + +my $server = MetaCPAN::TestServer->new; + +test_psgi app, sub { + my $cb = shift; + + { + my $module_name = 'CPAN::Test::Dummy::Perl5::VersionBump'; + ok( my $res = $cb->( GET "/package/$module_name" ), + "GET $module_name" ); + is( $res->code, 200, '200 OK' ); + + is_deeply( + decode_json( $res->content ), + { + module_name => $module_name, + version => '0.02', + file => + 'M/MI/MIYAGAWA/CPAN-Test-Dummy-Perl5-VersionBump-0.02.tar.gz', + author => 'MIYAGAWA', + distribution => 'CPAN-Test-Dummy-Perl5-VersionBump', + dist_version => '0.02', + }, + 'Has the correct 02packages info' + ); + } + + { + my $dist = 'File-Changes-UTF8'; + ok( my $res = $cb->( GET "/package/modules/$dist" ), + "GET modules/$dist" ); + is( $res->code, 200, '200 OK' ); + is_deeply( + decode_json( $res->content ), + { + modules => ['File::Changes::UTF8'], + }, + 'Can list modules of latest release' + ); + } +}; + +done_testing; diff --git a/t/server/controller/permission.t b/t/server/controller/permission.t new file mode 100644 index 000000000..9ac89b5ac --- /dev/null +++ b/t/server/controller/permission.t @@ -0,0 +1,51 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Cpanel::JSON::XS qw( decode_json ); +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestServer (); +use Test::More; + +my $server = MetaCPAN::TestServer->new; + +test_psgi app, sub { + my $cb = shift; + + { + my $module_name = 'CPAN::Test::Dummy::Perl5::VersionBump'; + ok( my $res = $cb->( GET "/permission/$module_name" ), + "GET $module_name" ); + is( $res->code, 200, '200 OK' ); + + is_deeply( + decode_json( $res->content ), + { + co_maintainers => ['OALDERS'], + module_name => $module_name, + owner => 'MIYAGAWA', + }, + 'Owned by MIYAGAWA, OALDERS has co-maint' + ); + } + + # Pod::Examples,RWSTAUNER,f + { + my $module_name = 'Pod::Examples'; + ok( my $res = $cb->( GET "/permission/$module_name" ), + "GET $module_name" ); + is( $res->code, 200, '200 OK' ); + + is_deeply( + decode_json( $res->content ), + { + co_maintainers => [], + module_name => $module_name, + owner => 'RWSTAUNER', + }, + 'Owned by RWSTAUNER, no co-maint' + ); + } +}; + +done_testing; diff --git a/t/server/controller/pod.t b/t/server/controller/pod.t new file mode 100644 index 000000000..9741ca9c2 --- /dev/null +++ b/t/server/controller/pod.t @@ -0,0 +1,189 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Cpanel::JSON::XS (); +use HTTP::Request::Common qw( GET ); +use MetaCPAN::Server (); +use MetaCPAN::TestHelpers qw( test_cache_headers ); +use Path::Tiny qw( path ); +use Plack::Test (); +use Test::More; +use Try::Tiny qw( try ); + +my $dir = path( MetaCPAN::Server->model('Source')->base_dir, + 'DOY/Moose-0.02/Moose-0.02' ); +$dir->mkpath; + +my $file = $dir->child('binary.bin'); +$file->openw->print( "\x00" x 10 ); + +my @tests = ( + { + url => '/pod/DOESNOTEXIST', + headers => { + code => 404, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + }, + }, + { + url => '/pod/DOY/Moose-0.02/binary.bin', + headers => { + code => 400, + cache_control => undef, + surrogate_key => + 'author=DOY content_type=application/json content_type=application', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + }, + { + url => '/pod/DOY/Moose-0.01/lib/Moose.pm', + headers => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=DOY content_type=text/html content_type=text', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + }, + { + url => '/pod/Moose', + headers => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=DOY content_type=text/html content_type=text', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + }, + { + url => '/pod/Pod::Pm', + headers => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=MO content_type=text/html content_type=text', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + }, +); + +my $app = MetaCPAN::Server->new->to_app(); +my $server = Plack::Test->create($app); + +for my $test (@tests) { + my $url = $test->{url}; + subtest $url => sub { + my $res = $server->request( GET $url ); + ok( $res, "GET $url" ); + is( + $res->code, + $test->{headers}->{code}, + "code " . $test->{headers}->{code} + ); + is( + $res->header('content-type'), + $test->{headers}->{code} == 200 + ? 'text/html; charset=UTF-8' + : 'application/json; charset=utf-8', + 'Content-type' + ); + + test_cache_headers( $res, $test->{headers} ); + + if ( $url eq '/pod/Pod::Pm' ) { + like( $res->content, qr/Pod::Pm - abstract/, 'NAME section' ); + } + elsif ( $test->{headers}->{code} == 200 ) { + like( $res->content, qr/Moose - abstract/, 'NAME section' ); + $res = $server->request( GET "$url?content-type=text/plain" ); + is( + $res->header('content-type'), + 'text/plain; charset=UTF-8', + 'Content-type' + ); + } + elsif ( $test->{headers}->{code} == 404 ) { + like( $res->content, qr/Not found/, '404 correct error' ); + } + + my $ct = $url =~ /Moose[.]pm$/ ? '&content-type=text/x-pod' : q[]; + $res = $server->request( GET "$url?callback=foo$ct" ); + is( + $res->code, + $test->{headers}->{code}, + "code " . $test->{headers}->{code} + ); + is( + $res->header('content-type'), + 'text/javascript; charset=UTF-8', + 'Content-type' + ); + + ok( my ($function_args) = $res->content =~ /^\/\*\*\/foo\((.*)\)/s, + 'callback included' ); + my $js_data; + try { + $js_data + = Cpanel::JSON::XS->new->allow_blessed->allow_nonref->binary + ->decode($function_args); + }; + ok( $js_data, 'decode json' ); + + if ( $test->{headers}->{code} eq 200 ) { + if ($ct) { + like( $js_data, qr{=head1 NAME}, + 'POD body was JSON encoded' ); + } + else { + like( + $js_data, + qr{

    NAME

    }, + 'HTML body was JSON encoded' + ); + } + } + else { + ok( $js_data->{message}, 'error response body was JSON encoded' ); + } + } +} + +{ + my $path = '/pod/BadPod'; + my $res = $server->request( GET $path ); + ok( $res, "GET $path" ); + is( $res->code, 200, 'code 200' ); + unlike( + $res->content, + qr/]*class="pod-errors"/, + 'no POD errors section' + ); + +} + +{ + my $path = '/pod/BadPod?show_errors=1'; + my $res = $server->request( GET $path ); + ok( $res, "GET $path" ); + is( $res->code, 200, 'code 200' ); + like( + $res->content, + qr/]*class="pod-errors"/, + 'got POD errors section' + ); + + my @err = $res->content =~ m{(.*?)}sg; + is( scalar(@err), 2, 'two parse errors listed ' ); + like( $err[0], qr/=head\b/, 'first error mentions =head' ); + like( $err[1], qr/C</, 'first error mentions C< ... >' ); +} + +done_testing; diff --git a/t/server/controller/rating.t b/t/server/controller/rating.t new file mode 100644 index 000000000..e59adb3c2 --- /dev/null +++ b/t/server/controller/rating.t @@ -0,0 +1,117 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET POST test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok ); +use Test::More; + +test_psgi app, sub { + my $cb = shift; + + my $res; + + ok( $res = $cb->( GET '/rating/random-id' ), 'GET /rating/random-id' ); + + is $res->code, 404, 'not found'; + + ok( $res = $cb->( GET '/rating/_mapping' ), 'GET /rating/_mapping' ); + + is $res->code, 404, 'not found'; + + ok( $res = $cb->( GET '/rating/by_distributions?distribution=Moose' ), + 'GET /rating/by_distributions' ); + + is $res->code, 200, 'found'; + + my $ratings = decode_json_ok($res); + + is_deeply $ratings->{distributions}, {}, 'empty distributions'; + + ok( + $res = $cb->( + POST '/rating/_search', + Content => '{"query":{"term":{"distribution":"Moose"}}}' + ), + 'POST /rating/_search' + ); + + is $res->code, 200, 'found'; + + $ratings = decode_json_ok($res); + + is_deeply $ratings->{hits}{hits}, [], 'no hits'; + + ok( + $res = $cb->( + POST '/rating', + Content => '{"query":{"term":{"distribution":"Moose"}}}', + ), + 'POST /rating' + ); + + is $res->code, 200, 'found'; + + $ratings = decode_json_ok($res); + + is_deeply $ratings->{hits}{hits}, [], 'no hits'; + + ok( + $res = $cb->( + POST '/rating/_search?scroll=5m', + Content => '{"query":{"term":{"distribution":"Moose"}}}', + ), + 'POST /rating' + ); + + is $res->code, 200, 'found'; + + $ratings = decode_json_ok($res); + + is_deeply $ratings->{hits}{hits}, [], 'no hits'; + + is_deeply $ratings->{_scroll_id}, 'FAKE_SCROLL_ID', + 'gives fake scroll id'; + + ok( + $res + = $cb->( POST "/_search/scroll/$ratings->{_scroll_id}?scroll=5m", + ), + 'POST /_search/scroll/$id', + ); + + is $res->code, 200, 'found' + or diag $res->as_string; + + $ratings = decode_json_ok($res); + + is_deeply $ratings->{hits}{hits}, [], 'working with no hits'; + is $ratings->{_shards}{total}, 0, 'results are fake'; + + ok( + $res = $cb->( + POST '/rating/_search', + 'User-Agent' => 'MetaCPAN::Client/2.031001', + Content => '{"query":{"term":{"distribution":"Moose"}}}', + ), + 'POST /rating with MetaCPAN::Client test UA' + ); + + is $res->code, 200, 'found'; + + $ratings = decode_json_ok($res); + + is_deeply $ratings->{hits}{hits}, + [ + { + _source => { + distribution => 'Moose', + }, + }, + ], + 'no hits'; + +}; + +done_testing; + diff --git a/t/server/controller/release.t b/t/server/controller/release.t new file mode 100644 index 000000000..340382e46 --- /dev/null +++ b/t/server/controller/release.t @@ -0,0 +1,109 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok test_cache_headers ); +use Test::More; + +{ + no warnings 'redefine'; + + sub get_ok { + my ( $cb, $url, $desc, $headers ) = @_; + ok( my $res = $cb->( GET $url ), $desc || "GET $url" ); + is( $res->code, 200, 'code 200' ); + + test_cache_headers( $res, $headers ); + + return $res; + } +} + +sub get_json_ok { + return decode_json_ok( get_ok(@_) ); +} + +test_psgi app, sub { + my $cb = shift; + + # find (/release/DIST) + get_json_ok( + $cb, + '/release/Moose', + 'GET /release/dist', + { + # ??? + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + } + ); + + # get (/release/AUTHOR/NAME) + get_json_ok( + $cb, + '/release/DOY/Moose-0.01', + 'GET /release/DOY/Moose-0.01', + { + # ??? + } + ); + + # versions (/release/versions/DIST) + my $versions = get_json_ok( + $cb, + '/release/versions/Moose', + 'GET /release/versions/Moose', + { + # ??? + } + ); + is( @{ $versions->{releases} }, 2, "Got 2 Moose versions (all)" ); + + # versions - specific (/release/versions/DIST?versions=VERSION) + my $versions_specific = get_json_ok( + $cb, + '/release/versions/Moose?versions=0.01', + 'GET /release/versions/Moose?versions=0.01', + { + # ??? + } + ); + is( @{ $versions_specific->{releases} }, + 1, "Got 1 Moose version (specificly requested)" ); + + # versions - latest (/release/versions/DIST?versions=latest) + my $versions_latest = get_json_ok( + $cb, + '/release/versions/Moose?versions=latest', + 'GET /release/versions/Moose?versions=latest', + { + # ??? + } + ); + is( @{ $versions_latest->{releases} }, + 1, "Got 1 Moose version (only latest requested)" ); + is( $versions_latest->{releases}[0]{status}, + 'latest', "Release status is latest" ); + + # versions - plain (/release/versions/DIST?plain=1) + ok( my $versions_plain = $cb->( GET '/release/versions/Moose?plain=1' ), + 'GET /release/versions/Moose?plain=1' ); + is( $versions_plain->code, 200, 'code 200' ); + ok( $versions_plain->content =~ /\A .+ \t .+ \n .+ \t .+ \z/xsm, + 'Content is plain text result' ); + + # latest_by_distribution (/release/latest_by_distribution/DIST) + get_json_ok( + $cb, + '/release/latest_by_distribution/Moose', + 'GET /release/latest_by_distribution/Moose', + { + # ??? + } + ); +}; + +done_testing; diff --git a/t/server/controller/root.t b/t/server/controller/root.t new file mode 100644 index 000000000..5039474a5 --- /dev/null +++ b/t/server/controller/root.t @@ -0,0 +1,19 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use Test::More; + +test_psgi app, sub { + my $cb = shift; + ok( my $res = $cb->( GET '/' ), "GET /" ); + is( $res->code, 302, 'got redirect' ); + is( + $res->header('Location'), + 'https://github.com/metacpan/metacpan-api/blob/master/docs/API-docs.md', + 'correct redirect target' + ); +}; + +done_testing; diff --git a/t/server/controller/scroll.t b/t/server/controller/scroll.t new file mode 100644 index 000000000..e9ba29ce1 --- /dev/null +++ b/t/server/controller/scroll.t @@ -0,0 +1,129 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET POST test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok test_cache_headers ); +use Test::More; + +test_psgi app, sub { + my $cb = shift; + + test_scroll_methods($cb); + test_missing_scroll_id($cb); +}; + +sub test_missing_scroll_id { + my $cb = shift; + foreach my $req ( + [ scroll_url_param(), 'url param' ], + [ scroll_post_body(), 'post body' ], + [ scroll_query_string(), 'query string' ], + ) + { + is_deeply( + req_json( $cb, @$req, 500 ), + { message => 'Scroll Id required' }, + "error without scroll_id in $req->[-1]", + ); + } +} + +sub scroll_start { + return GET '/release/_search?size=1&scroll=5m'; +} + +sub scroll_url_param { + my $scroll_id = shift || q[]; + return GET "/_search/scroll/$scroll_id?scroll=5m"; +} + +sub scroll_post_body { + my $scroll_id = shift || q[]; + + # Use text/plain to avoid Catalyst trying to process the body. + return POST '/_search/scroll?scroll=5m', + Content_type => 'text/plain', + Content => $scroll_id; +} + +sub scroll_query_string { + my $scroll_id = shift || q[]; + return GET "/_search/scroll/?scroll_id=$scroll_id&scroll=5m"; +} + +sub req_json { + my ( $cb, $req, $desc, $code ) = @_; + ok( my $res = $cb->($req), $desc ); + + $code ||= 200; + is( $res->code, $code, "HTTP $code" ) + or diag Test::More::explain($res); + + test_cache_headers( + $res, + { + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + } + ); + + my $json = decode_json_ok($res); + return $json; +} + +sub test_scroll_methods { + my $cb = shift; + + my $steps = [ + sub { + req_json( $cb, scroll_start(), 'start scroll' ); + }, + sub { + req_json( + $cb, + scroll_url_param( $_[0] ), + 'continue scroll with scroll_id in GET url' + ); + }, + sub { + req_json( + $cb, + scroll_post_body( $_[0] ), + 'continue scroll with scroll_id in POST body' + ); + }, + sub { + req_json( + $cb, + scroll_query_string( $_[0] ), + 'continue scroll with scroll_id in query string' + ); + }, + ]; + + my $scroll_id; + my @docs; + + # Repeat each type just to be sure. + foreach my $step ( shift(@$steps), (@$steps) x 2 ) { + + # Pass in previous scroll_id. + my $json = $step->($scroll_id); + + # Cache scroll_id for next call. + $scroll_id = $json->{_scroll_id}; + + # Save docs. + push @docs, $json->{hits}{hits}[0]; + note $docs[-1]->{_source}{name}; + } + + my %ids = map { ( $_->{_id} => $_ ) } @docs; + + is scalar( keys(%ids) ), scalar(@docs), 'got a new doc each time'; +} + +done_testing; diff --git a/t/server/controller/search/autocomplete.t b/t/server/controller/search/autocomplete.t new file mode 100644 index 000000000..760cd7405 --- /dev/null +++ b/t/server/controller/search/autocomplete.t @@ -0,0 +1,64 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok ); +use Test::More; + +test_psgi app, sub { + my $cb = shift; + + # test ES script using doc['blah'] value + { + ok( my $res = $cb->( GET '/search/autocomplete?q=Multiple::Modu' ), + 'GET' ); + my $json = decode_json_ok($res); + + my $got = [ map { $_->{fields}{documentation} } + @{ $json->{hits}{hits} } ]; + + is_deeply $got, [ qw( + Multiple::Modules + Multiple::Modules::A + Multiple::Modules::B + Multiple::Modules::RDeps + Multiple::Modules::Tester + Multiple::Modules::RDeps::A + Multiple::Modules::RDeps::Deprecated + ) ], + 'results are sorted lexically by module name + length' + or diag( Test::More::explain($got) ); + } +}; + +test_psgi app, sub { + my $cb = shift; + + # test ES script using doc['blah'] value + { + ok( + my $res + = $cb->( + GET '/search/autocomplete/suggest?q=Multiple::Modu' ), + 'GET' + ); + my $json = decode_json_ok($res); + + my $got = [ map $_->{name}, @{ $json->{suggestions} } ]; + + is_deeply $got, [ qw( + Multiple::Modules + Multiple::Modules::A + Multiple::Modules::B + Multiple::Modules::RDeps + Multiple::Modules::Tester + Multiple::Modules::RDeps::A + Multiple::Modules::RDeps::Deprecated + ) ], + 'results are sorted lexically by module name + length' + or diag( Test::More::explain($got) ); + } +}; + +done_testing; diff --git a/t/server/controller/source.t b/t/server/controller/source.t new file mode 100644 index 000000000..97d5f8cd1 --- /dev/null +++ b/t/server/controller/source.t @@ -0,0 +1,169 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Cpanel::JSON::XS (); +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( test_cache_headers ); +use Test::More; + +my %tests = ( + '/source/DOESNEXIST' => { + code => 404, + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef + }, + '/source/DOY/Moose-0.01/' => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=DOY content_type=text/html content_type=text', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000' + }, + '/source/DOY/Moose-0.01/Changes' => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=DOY content_type=text/plain content_type=text', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + '/source/DOY/Moose-0.01/Changes?callback=foo' => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=DOY content_type=text/javascript content_type=text', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + '/source/DOY/Moose-0.01/MANIFEST' => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=DOY content_type=text/plain content_type=text', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + '/source/DOY/Moose-0.01/MANIFEST?callback=foo' => { + code => 200, + cache_control => undef, + surrogate_key => + 'author=DOY content_type=text/javascript content_type=text', + surrogate_control => + 'max-age=31556952, stale-while-revalidate=86400, stale-if-error=2592000', + }, + '/source/Moose' => { + code => 200, + cache_control => 'private', + surrogate_key => + 'author=DOY content_type=text/plain content_type=text', + surrogate_control => undef + }, +); + +test_psgi app, sub { + my $cb = shift; + while ( my ( $k, $v ) = each %tests ) { + ok( my $res = $cb->( GET $k ), "GET $k" ); + is( $res->code, $v->{code}, "code " . $v->{code} ); + + test_cache_headers( $res, $v ); + + if ( $k eq '/source/Moose' ) { + like( $res->content, qr/package Moose/, 'Moose source' ); + is( $res->header('content-type'), 'text/plain', 'Content-type' ); + + # Used for fastly on st.aticpan.org + is( $res->header('X-Content-Type'), + 'text/x-script.perl-module', 'X-Content-Type' ); + + } + elsif ( $k =~ /MANIFEST/ ) { + + # No EOL. + my $manifest = join( + "\n", qw( + MANIFEST + lib/Moose.pm + Makefile.PL + t/00-nop.t + META.json + META.yml + ) + ); + if ( $k =~ /callback=foo/ ) { + ok( + my ($function_args) + = $res->content =~ /^\/\*\*\/foo\((.*)\)/s, + 'JSONP wrapper' + ); + ok( + my $jsdata = Cpanel::JSON::XS->new->allow_nonref->decode( + $function_args), + 'decode json' + ); + is( $jsdata, $manifest, 'JSONP-wrapped manifest' ); + is( + $res->header('content-type'), + 'text/javascript; charset=UTF-8', + 'Content-type' + ); + } + else { + is( $res->content, $manifest, 'Plain text manifest' ); + is( $res->header('content-type'), + 'text/plain', 'Content-type' ); + } + } + elsif ( $k eq '/source/DOY/Moose-0.01/Changes' ) { + is( $res->header('content-type'), 'text/plain', 'Content-type' ); + like( + $res->decoded_content, + qr/codename 'M\x{fc}nchen'/, + 'Change-log content' + ); + } + elsif ( $k eq '/source/DOY/Moose-0.01/Changes?callback=foo' ) { + is( + $res->header('content-type'), + 'text/javascript; charset=UTF-8', + 'Content-type' + ); + ok( + my ($function_args) + = $res->content =~ /^\/\*\*\/foo\((.*)\)/s, + 'JSONP wrapper' + ); + ok( + my $jsdata = JSON->new->allow_nonref->decode($function_args), + 'decode json' + ); + like( + $jsdata, + qr/codename 'M\x{fc}nchen'/, + 'JSONP-wrapped change-log' + ); + } + elsif ( $v->{code} eq 200 ) { + like( $res->content, qr/Index of/, 'Index of' ); + is( + $res->header('content-type'), + 'text/html; charset=UTF-8', + 'Content-type' + ); + + } + else { + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + } + } +}; + +done_testing; diff --git a/t/server/controller/url_parameters.pm b/t/server/controller/url_parameters.pm new file mode 100644 index 000000000..58639a356 --- /dev/null +++ b/t/server/controller/url_parameters.pm @@ -0,0 +1,63 @@ +use strict; +use warnings; + +use Cpanel::JSON::XS (); +use HTTP::Request::Common qw( GET ); +use MetaCPAN::Server (); +use MetaCPAN::TestHelpers qw( test_cache_headers ); +use Plack::Test (); +use Ref::Util qw( is_arrayref is_hashref ); +use Test::More; + +my $app = MetaCPAN::Server->new->to_app(); +my $test = Plack::Test->create($app); + +subtest "parem 'source'" => sub { + my $source = Cpanel::JSON::XS::encode_json { + query => { + term => { distribution => "Moose" } + }, + aggs => { + count => { + terms => { field => "distribution" } + } + }, + size => 0, + }; + + # test different types, as the parameter is generic to all + for ( [ release => 2 ], [ file => 27 ] ) { + my ( $type, $count ) = @$_; + + my $url = "/$type/_search?source=$source"; + + subtest "check with '$type' controller" => sub { + my $res = $test->request( GET $url ); + ok( $res, "GET $url" ); + is( $res->code, 200, "code 200" ); + test_cache_headers( + $res, + { + cache_control => 'private', + surrogate_key => + 'content_type=application/json content_type=application', + surrogate_control => undef, + + } + ); + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + my $content = Cpanel::JSON::XS::decode_json $res->content; + ok( is_hashref($content), 'content is a JSON object' ); + my $buckets = $content->{aggregations}{count}{buckets}; + ok( is_arrayref($buckets), 'we have aggregation buckets' ); + is( @{$buckets}, 1, 'one key (Moose)' ); + is( $buckets->[0]{doc_count}, $count, "record count is $count" ); + }; + } +}; + +done_testing; diff --git a/t/server/controller/user/favorite.t b/t/server/controller/user/favorite.t new file mode 100644 index 000000000..6aebeccad --- /dev/null +++ b/t/server/controller/user/favorite.t @@ -0,0 +1,64 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app DELETE GET POST test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok encode_json ); +use Test::More; + +test_psgi app, sub { + my $cb = shift; + + ok( my $user = $cb->( GET '/user?access_token=testing' ), 'get user' ); + is( $user->code, 200, 'code 200' ); + $user = decode_json_ok($user); + + is_deeply( + $user->{identity}, + [ { + 'key' => 'MO', + 'name' => 'pause' + } ], + 'got correct identity' + ); + + is_deeply( + $user->{access_token}, + [ { + 'client' => 'testing', + 'token' => 'testing' + } ], + 'got correct access_token' + ); + + ok( + my $res = $cb->( + POST '/user/favorite?access_token=testing', + Content_Type => 'application/json', + Content => encode_json( { + distribution => 'Moose', + release => 'Moose-1.10', + author => 'DOY' + } ) + ), + 'POST favorite' + ); + is( $res->code, 201, 'status created' ); + ok( my $location = $res->header('location'), 'location header set' ); + ok( $res = $cb->( GET $location ), "GET $location" ); + is( $res->code, 200, 'found' ); + + my $json = decode_json_ok($res); + is( $json->{user}, $user->{id}, 'user is ' . $user->{id} ); + ok( $res = $cb->( DELETE '/user/favorite/Moose?access_token=testing' ), + 'DELETE /user/favorite/MO/Moose' ); + is( $res->code, 200, 'status ok' ); + ok( $res = $cb->( GET "$location?access_token=testing" ), + "GET $location" ); + is( $res->code, 404, 'not found' ); + + ok( $user = $cb->( GET '/user?access_token=bot' ), 'get bot' ); + is( $user->code, 200, 'code 200' ); +}; + +done_testing; diff --git a/t/server/not_found.t b/t/server/not_found.t new file mode 100644 index 000000000..ee2666d28 --- /dev/null +++ b/t/server/not_found.t @@ -0,0 +1,43 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET test_psgi ); +use MetaCPAN::TestHelpers qw( decode_json_ok ); +use Test::More; + +my @tests = ( + [ '/changes/LOCAL/File-Changes-2' => 404 ], + [ '/changes/LOCAL/File-Changes-2.0' => 200 ], + [ '/fakedoctype/andaction' => 404 ], + [ '/file/LOCAL/File-Changes-2.0/Changes' => 200 ], + [ '/file/LOCAL/File-Changes-2.0/NoChanges' => 404 ], + [ '/release/File-Changes' => 200 ], + [ '/release/No-Dist-Here' => 404 ], + [ '/root.file' => 404 ], +); + +test_psgi app, sub { + my $cb = shift; + for my $test (@tests) { + my ( $path, $code ) = @{$test}; + + ok( my $res = $cb->( GET $path ), "GET $path" ); + is( $res->code, $code, "code $code" ); + + # 404 should still be json + is( + $res->header('content-type'), + 'application/json; charset=utf-8', + 'Content-type' + ); + my $json = decode_json_ok($res); + + next unless $res->code == 404; + + is( $json->{message}, 'Not found', '404 message as expected' ); + is( $json->{code}, $code, 'code as expected' ); + } +}; + +done_testing; diff --git a/t/server/sanitize_query.t b/t/server/sanitize_query.t new file mode 100644 index 000000000..d843eeb33 --- /dev/null +++ b/t/server/sanitize_query.t @@ -0,0 +1,147 @@ +use strict; +use warnings; +use lib 't/lib'; + +use MetaCPAN::Server::Test qw( app GET POST test_psgi ); +use MetaCPAN::TestHelpers qw( catch decode_json_ok encode_json try ); +use Test::More skip_all => 'Scripting is disabled'; +use URI (); + +sub uri { + my $uri = URI->new(shift); + $uri->query_form( {@_} ); + return $uri->as_string; +} + +sub like_if_defined { + my ( $val, $exp, $desc ) = @_; + defined($exp) ? like( $val, $exp, $desc ) : is( $val, undef, $desc ); +} + +my $error_message = 'Parameter "script" not allowed'; + +my %errors = ( + 'filter:script' => + '{"query":{"match_all":{}},"filter":{"script":{"script":"true"}}}', + 'filtered query custom_score' => { + filtered => { + query => { + custom_score => { + query => { who => 'cares' }, + script => 'anything', + }, + }, + filter => { dont => 'care' }, + } + }, +); + +test_psgi app, sub { + my $cb = shift; + while ( my ( $desc, $search ) = each %errors ) { + test_all_methods( + $search, + sub { + my ($req) = shift; + test_bad_request( $cb, $desc, $search, $req ); + } + ); + } +}; + +sub test_all_methods { + my ( $search, $sub ) = @_; + $search = encode_json($search) if ref($search) eq 'HASH'; + + foreach my $req ( + POST( '/author/_search', Content => $search ), + GET( uri( '/author/_search', source => $search ) ), + ) + { + $sub->($req); + } +} + +sub test_bad_request { + my ( $cb, $desc, $search, $req ) = @_; + my $method = $req->method; + subtest "bad request for $method '$desc'" => sub { + if ( $method eq 'GET' ) { + like $req->uri, qr/\?source=%7B%22(query|filtered)%22%3A/, + 'uri has json in querystring'; + } + else { + like $req->content, qr/{"(query|filtered)":/, 'body is json'; + } + + ok( my $res = $cb->($req), "$method request" ); + + is $res->code, 403, 'Not allowed'; + + my $json = decode_json_ok($res) + or diag explain $res; + + is_deeply $json, { message => "$error_message" }, + "error returned for $desc"; + }; +} + +hash_key_rejected( script => { script => 'foobar' } ); +hash_key_rejected( + script => { tree => { of => 'many', hashes => { script => 'foobar' } } } +); +hash_key_rejected( + script => { + with => { arrays => [ { of => 'hashes' }, { script => 'foobar' } ] } + } +); + +{ + my $hash = filtered_custom_score_hash( hi => 'there' ); + + is_deeply delete $hash->{query}{filtered}{query}, + { custom_score => { query => { foo => 'bar' }, hi => 'there' } }, + 'remove custom_score hash'; + + $hash->{query}{filtered}{fooey} = {}; + + is_deeply + +MetaCPAN::Server::QuerySanitizer->new( query => $hash )->query, + { query => { filtered => { fooey => {} } }, }, + 'test that sanitizing does not autovivify hash keys'; +} + +done_testing; + +sub filtered_custom_score_hash { + return { + query => { + filtered => { + query => { + custom_score => { + query => { foo => 'bar' }, + @_ + } + } + } + } + }; +} + +sub hash_key_rejected { + my ( $key, $hash ) = @_; + my $e; + try { + MetaCPAN::Server::QuerySanitizer->new( query => $hash )->query; + } + catch { + $e = $_[0]; + }; + + if ( defined $e ) { + like $e, qr/Parameter "$key" not allowed/, "died for bad key '$key'"; + } + else { + ok 0, 'error expected but not found'; + } +} diff --git a/t/test-vars.t b/t/test-vars.t new file mode 100644 index 000000000..1787f26c4 --- /dev/null +++ b/t/test-vars.t @@ -0,0 +1,10 @@ +use strict; +use warnings; +use lib 't/lib'; + +use Test::More; +use Test::Vars import => [qw( vars_ok )]; + +vars_ok('MetaCPAN::Server'); + +done_testing(); diff --git a/t/testrules.yml b/t/testrules.yml new file mode 100644 index 000000000..f5e59287f --- /dev/null +++ b/t/testrules.yml @@ -0,0 +1,9 @@ +--- +seq: + - seq: t/0*.t + + # ensure t/script/cover.t runs before t/server/controller/cover.t + - seq: t/script/cover.t + + - par: + - t/**.t diff --git a/t/types.t b/t/types.t new file mode 100644 index 000000000..3f8ce75a5 --- /dev/null +++ b/t/types.t @@ -0,0 +1,92 @@ +use strict; +use warnings; + +use MetaCPAN::Types::TypeTiny qw( Resources ); +use Test::More; + +is_deeply( + Resources->coerce( { + license => ['http://dev.perl.org/licenses/'], + homepage => 'http://sourceforge.net/projects/module-build', + bugtracker => { + web => 'http://github.com/dagolden/cpan-meta-spec/issues', + mailto => 'meta-bugs@example.com', + }, + repository => { + url => 'git://github.com/dagolden/cpan-meta-spec.git', + web => 'http://github.com/dagolden/cpan-meta-spec', + type => 'git', + }, + x_twitter => 'http://twitter.com/cpan_linked/', + } ), + { + license => ['http://dev.perl.org/licenses/'], + homepage => 'http://sourceforge.net/projects/module-build', + bugtracker => { + web => 'http://github.com/dagolden/cpan-meta-spec/issues', + mailto => 'meta-bugs@example.com', + }, + repository => { + url => 'git://github.com/dagolden/cpan-meta-spec.git', + web => 'http://github.com/dagolden/cpan-meta-spec', + type => 'git', + } + }, + 'coerce CPAN::Meta::Spec example' +); + +ok( + Resources->check( Resources->coerce( { + license => ['http://dev.perl.org/licenses/'], + homepage => 'http://sourceforge.net/projects/module-build', + bugtracker => { + web => 'http://github.com/dagolden/cpan-meta-spec/issues', + mailto => 'meta-bugs@example.com', + }, + repository => { + url => 'git://github.com/dagolden/cpan-meta-spec.git', + web => 'http://github.com/dagolden/cpan-meta-spec', + type => 'git', + }, + x_twitter => 'http://twitter.com/cpan_linked/', + } ) ), + 'check CPAN::Meta::Spec example' +); + +is_deeply( + Resources->coerce( { + license => ['http://dev.perl.org/licenses/'], + homepage => 'http://sourceforge.net/projects/module-build', + } ), + { + homepage => 'http://sourceforge.net/projects/module-build', + license => ['http://dev.perl.org/licenses/'], + }, + 'coerce sparse resources' +); + +ok( + Resources->check( { + license => ['http://dev.perl.org/licenses/'], + homepage => 'http://sourceforge.net/projects/module-build', + } ), + 'check sparse resources' +); + +ok( + Resources->check( { + bugtracker => { + web => + 'https://github.com/AlexBio/Dist-Zilla-Plugin-GitHub/issues' + }, + homepage => 'http://search.cpan.org/dist/Dist-Zilla-Plugin-GitHub/', + repository => { + type => 'git', + url => 'git://github.com/AlexBio/Dist-Zilla-Plugin-GitHub.git', + web => 'https://github.com/AlexBio/Dist-Zilla-Plugin-GitHub' + } + } ), + 'sparse' +); + +done_testing; diff --git a/t/util.t b/t/util.t new file mode 100644 index 000000000..e53d06014 --- /dev/null +++ b/t/util.t @@ -0,0 +1,116 @@ +use strict; +use warnings; +use lib 't/lib'; + +use CPAN::Meta (); +use MetaCPAN::Util qw( + extract_section + generate_sid + numify_version + strip_pod +); + +use Test::Fatal qw( exception ); +use Test::More; + +ok( generate_sid(), 'generate_sid' ); + +{ + my %versions = ( + '010' => 10, + '0.20_8' => 0.208, + '0.208_8' => 0.2088, + '0.20_88' => 0.2088, + 1 => 1, + LATEST => 0, + undef => 0, + 'v0.9_9' => 0.099, + 'v2.1.1' => 2.001001, + 'v2.0.0' => 2.0, + ); + + foreach my $before ( sort keys %versions ) { + is( numify_version($before), $versions{$before}, + "$before => $versions{$before}" ); + } +} + +{ + my %versions = ( + '2a' => 2, + 'V0.01' => 'v0.01', + '0.99_1' => '0.99_1', + '0.99.01' => 'v0.99.01', + 'v1.2' => 'v1.2', + ); + foreach my $before ( sort keys %versions ) { + is exception { + is( version($before), $versions{$before}, + "$before => $versions{$before}" ) + }, undef, "$before => $versions{$before} does not die"; + } +} + +is( + strip_pod('hello L foo'), + 'hello link foo', + 'strip_pod strips http links' +); +is( + strip_pod('hello L foo'), + 'hello section in Module foo', + 'strip_pod strips internal links' +); +is( + strip_pod('for L'), + 'for Dist::Zilla', + 'strip_pod strips module links' +); +is( + strip_pod('without a leading C<$>.'), + 'without a leading $.', + 'strip_pod strips C<>' +); + +sub version { + CPAN::Meta->new( { + name => 'foo', + license => 'unknown', + version => MetaCPAN::Util::fix_version(shift) + } )->version; +} + +# extract_section tests + +{ + my $content = < + + + MO + author + Moritz Onken + onken@netcubed.de + http://blog.netcubed.de + 1 + + + MOFAKE + author + Moritz Onken + onken@netcubed.de + http://blog.netcubed.de + 1 + + + DOY + author + Who Knows + doy@cpan.org + 1 + + + RWSTAUNER + author + Trouble Maker + rwstauner@cpan.org + 1 + + + BORISNAT + author + Лось и Белка + Moose and Squirrel + borisnat@cpan.org + 1 + + diff --git a/test-data/fakecpan/08pumpkings.txt.gz b/test-data/fakecpan/08pumpkings.txt.gz new file mode 100644 index 000000000..8493534c1 Binary files /dev/null and b/test-data/fakecpan/08pumpkings.txt.gz differ diff --git a/test-data/fakecpan/author-1.0.json b/test-data/fakecpan/author-1.0.json new file mode 100644 index 000000000..a087793c6 --- /dev/null +++ b/test-data/fakecpan/author-1.0.json @@ -0,0 +1,48 @@ +{ + "profile" : [ + { + "name" : "github", + "id" : "monken" + }, + { + "name" : "facebook", + "id" : "moritz.onken" + }, + { + "name" : "twitter", + "id" : "moritzonken" + } + ], + "country" : "DE", + "website" : [ + "http://metacpan.org/" + ], + "donation" : [ + { + "name" : "paypal", + "id" : "onken@houseofdesign.de" + } + ], + "perlmongers": { + "name": "test.pm" + }, + "region" : "BW", + "asciiname" : null, + "name" : "Moritz Onken", + "blog" : [ + { + "feed" : "http://blogs.perl.org/users/mo/atom.xml", + "url" : "http://blogs.perl.org/users/mo/" + }, + { + "feed" : "http://blog.netcubed.de/feed/", + "url" : "http://blog.netcubed.de/" + } + ], + "dir" : "id/P/PE/PERLER", + "email" : [ + "onken@netcubed.de" + ], + "city" : "Karlsruhe", + "pauseid" : "PERLER" +} \ No newline at end of file diff --git a/test-data/fakecpan/bugs.tsv b/test-data/fakecpan/bugs.tsv new file mode 100644 index 000000000..4aa0e76e3 --- /dev/null +++ b/test-data/fakecpan/bugs.tsv @@ -0,0 +1,6 @@ +# A fake https://rt.cpan.org/Public/bugs-per-dist.tsv +# dist new open stalled patched resolved rejected active inactive +Monkey-Patch 0 0 0 0 1 0 0 1 +Moo 2 5 0 0 2 1 7 3 +Moose 15 20 4 0 122 23 39 145 +Text-Tabs+Wrap 2 0 0 0 15 1 2 16 diff --git a/test-data/fakecpan/configs/MIYAGAWA_CPAN-Test-Dummy-Perl5-VersionBump-0.01.tar.gz.dist b/test-data/fakecpan/configs/MIYAGAWA_CPAN-Test-Dummy-Perl5-VersionBump-0.01.tar.gz.dist new file mode 100644 index 000000000..b70271c26 Binary files /dev/null and b/test-data/fakecpan/configs/MIYAGAWA_CPAN-Test-Dummy-Perl5-VersionBump-0.01.tar.gz.dist differ diff --git a/test-data/fakecpan/configs/MIYAGAWA_CPAN-Test-Dummy-Perl5-VersionBump-0.02.tar.gz.dist b/test-data/fakecpan/configs/MIYAGAWA_CPAN-Test-Dummy-Perl5-VersionBump-0.02.tar.gz.dist new file mode 100644 index 000000000..b68e49094 Binary files /dev/null and b/test-data/fakecpan/configs/MIYAGAWA_CPAN-Test-Dummy-Perl5-VersionBump-0.02.tar.gz.dist differ diff --git a/test-data/fakecpan/configs/badpod.json b/test-data/fakecpan/configs/badpod.json new file mode 100644 index 000000000..76f7c4d1b --- /dev/null +++ b/test-data/fakecpan/configs/badpod.json @@ -0,0 +1,12 @@ +{ + "name": "BadPod", + "abstract": "Distribution with malformed POD", + "X_Module_Faker": { + "cpan_author": "MO", + "omitted_files": ["META.json", "META.yml"], + "append": [ { + "file": "lib/BadPod.pm", + "content": "\n\n=head1 NAME\n\nBadPod - Malformed POD\n\n=head SYNOPSIS\n\nThere is no C 'Binary-Data', + abstract => 'Binary after __DATA__ token', + version => '0.01', + + # Specify provides so that both modules are included + # in release 'provides' list and the release will get marked as latest. + provides => { + 'Binary::Data' => { + file => 'lib/Binary/Data.pm', + version => '0.01' + }, + 'Binary::Data::WithPod' => { + file => 'lib/Binary/Data/WithPod.pm', + version => '0.02' + } + }, + + X_Module_Faker => { + cpan_author => 'BORISNAT', + append => [ + { + file => 'lib/Binary/Data.pm', + content => < 'lib/Binary/Data/WithPod.pm', + 'content' => <