├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── run.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENCE ├── LICENSE ├── README.md ├── Rakefile ├── app.json ├── docs └── images │ ├── customised-message-example.png │ └── default-message-example.png ├── fixtures └── vcr_cassettes │ └── fresh.yml ├── lib ├── notification │ ├── expired.rb │ └── will_expire_by.rb ├── notifier.rb └── page.rb └── spec ├── notifier_spec.rb └── spec_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: / 5 | schedule: 6 | interval: daily 7 | allow: 8 | # Framework gems 9 | - dependency-name: rake 10 | dependency-type: direct 11 | - dependency-name: rspec 12 | dependency-type: direct 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | jobs: 3 | test: 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v2 7 | - uses: ruby/setup-ruby@v1 8 | with: 9 | bundler-cache: true 10 | - run: bundle exec rspec 11 | -------------------------------------------------------------------------------- /.github/workflows/run.yml: -------------------------------------------------------------------------------- 1 | on: 2 | schedule: 3 | - cron: '5 12 * * 3' # 12:05 UTC, Weekly on a Wednesday 4 | workflow_dispatch: 5 | 6 | jobs: 7 | daniel_the_manual_spaniel: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | bundler-cache: true 16 | 17 | - run: bundle exec rake notify:expired 18 | env: 19 | REALLY_POST_TO_SLACK: ${{ (github.event_name == 'schedule') && 1 || 0 }} 20 | SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | # Gemfile.lock 49 | # .ruby-version 50 | # .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 56 | # .rubocop-https?--* 57 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.5 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby File.read('.ruby-version').strip 4 | 5 | gem 'activesupport' 6 | gem 'chronic' 7 | gem 'http' 8 | gem 'octokit' 9 | gem 'rake' 10 | 11 | group :test do 12 | gem 'rspec' 13 | gem 'timecop' 14 | gem 'vcr' 15 | gem 'webmock' 16 | end 17 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (5.2.4.3) 5 | concurrent-ruby (~> 1.0, >= 1.0.2) 6 | i18n (>= 0.7, < 2) 7 | minitest (~> 5.1) 8 | tzinfo (~> 1.1) 9 | addressable (2.8.0) 10 | public_suffix (>= 2.0.2, < 5.0) 11 | chronic (0.10.2) 12 | concurrent-ruby (1.1.6) 13 | crack (0.4.3) 14 | safe_yaml (~> 1.0.0) 15 | diff-lcs (1.5.0) 16 | domain_name (0.5.20190701) 17 | unf (>= 0.0.5, < 1.0.0) 18 | faraday (1.5.1) 19 | faraday-em_http (~> 1.0) 20 | faraday-em_synchrony (~> 1.0) 21 | faraday-excon (~> 1.1) 22 | faraday-httpclient (~> 1.0.1) 23 | faraday-net_http (~> 1.0) 24 | faraday-net_http_persistent (~> 1.1) 25 | faraday-patron (~> 1.0) 26 | multipart-post (>= 1.2, < 3) 27 | ruby2_keywords (>= 0.0.4) 28 | faraday-em_http (1.0.0) 29 | faraday-em_synchrony (1.0.0) 30 | faraday-excon (1.1.0) 31 | faraday-httpclient (1.0.1) 32 | faraday-net_http (1.0.1) 33 | faraday-net_http_persistent (1.2.0) 34 | faraday-patron (1.0.0) 35 | ffi (1.15.5) 36 | ffi-compiler (1.0.1) 37 | ffi (>= 1.0.0) 38 | rake 39 | hashdiff (0.3.7) 40 | http (5.0.4) 41 | addressable (~> 2.8) 42 | http-cookie (~> 1.0) 43 | http-form_data (~> 2.2) 44 | llhttp-ffi (~> 0.4.0) 45 | http-cookie (1.0.4) 46 | domain_name (~> 0.5) 47 | http-form_data (2.3.0) 48 | i18n (1.8.2) 49 | concurrent-ruby (~> 1.0) 50 | llhttp-ffi (0.4.0) 51 | ffi-compiler (~> 1.0) 52 | rake (~> 13.0) 53 | minitest (5.14.1) 54 | multipart-post (2.1.1) 55 | octokit (4.12.0) 56 | sawyer (~> 0.8.0, >= 0.5.3) 57 | public_suffix (4.0.6) 58 | rake (13.1.0) 59 | rspec (3.12.0) 60 | rspec-core (~> 3.12.0) 61 | rspec-expectations (~> 3.12.0) 62 | rspec-mocks (~> 3.12.0) 63 | rspec-core (3.12.0) 64 | rspec-support (~> 3.12.0) 65 | rspec-expectations (3.12.0) 66 | diff-lcs (>= 1.2.0, < 2.0) 67 | rspec-support (~> 3.12.0) 68 | rspec-mocks (3.12.0) 69 | diff-lcs (>= 1.2.0, < 2.0) 70 | rspec-support (~> 3.12.0) 71 | rspec-support (3.12.0) 72 | ruby2_keywords (0.0.4) 73 | safe_yaml (1.0.4) 74 | sawyer (0.8.2) 75 | addressable (>= 2.3.5) 76 | faraday (> 0.8, < 2.0) 77 | thread_safe (0.3.6) 78 | timecop (0.9.1) 79 | tzinfo (1.2.7) 80 | thread_safe (~> 0.1) 81 | unf (0.1.4) 82 | unf_ext 83 | unf_ext (0.0.8) 84 | vcr (6.0.0) 85 | webmock (3.4.2) 86 | addressable (>= 2.3.6) 87 | crack (>= 0.3.2) 88 | hashdiff 89 | 90 | PLATFORMS 91 | ruby 92 | 93 | DEPENDENCIES 94 | activesupport 95 | chronic 96 | http 97 | octokit 98 | rake 99 | rspec 100 | timecop 101 | vcr 102 | webmock 103 | 104 | RUBY VERSION 105 | ruby 2.7.5p203 106 | 107 | BUNDLED WITH 108 | 2.1.4 109 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Crown Copyright (Government Digital Service) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Crown Copyright (Government Digital Service) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tech Docs Template - page expiry notifier 2 | 3 | This repo is part of the [tech-docs-template][template], and is used in 4 | conjunction with the [page expiry feature][expiry] that is part of the 5 | [tech-docs-gem][gem] 6 | 7 | GitHub Actions will run the script once a day during weekdays. 8 | It will look at the pages API for your site, find all pages that have expired, and post a Slack message to the owner of each page to let them know that it needs reviewing. 9 | 10 | [template]: https://github.com/alphagov/tech-docs-template 11 | [expiry]: https://alphagov.github.io/tech-docs-manual/#last-reviewed-on-and-review-in 12 | [gem]: https://github.com/alphagov/tech-docs-gem 13 | 14 | ## Usage 15 | 16 | ### `alphagov` users 17 | 18 | If you are part of the `alphagov` GitHub organisation you can enable the notifier by raising a PR to add your published documentation to the [`Rakefile`](Rakefile): 19 | 20 | ``` 21 | pages_urls = [ 22 | "https://gds-way.cloudapps.digital/api/pages.json", 23 | "https://docs.publishing.service.gov.uk/api/pages.json", 24 | "your-docs-site.cloudapps.digital" 25 | ] 26 | ``` 27 | 28 | If you want to limit the number of links that are posted to Slack after a single run, add this to `limits` in the [`Rakefile`](Rakefile) 29 | 30 | ``` 31 | limits = { 32 | "your-docs-site.cloudapps.digital" => 3 33 | } 34 | ``` 35 | 36 | The default behaviour is no limit, and the Slack message will contain all pages discovered. 37 | 38 | ### General configuration 39 | 40 | The following environment variables are necessary: 41 | 42 | * `SLACK_WEBHOOK_URL`: The Slack webhook URL to allow messages to be posted. 43 | * `REALLY_POST_TO_SLACK`: Messages will only be posted to Slack if the value of 44 | this var is `1`. 45 | 46 | #### Slack message customisation 47 | 48 | This is the default Slack message when pages expire: 49 | 50 | ![default-message-example](docs/images/default-message-example.png) 51 | 52 | You can customise parts of the Slack message by configuring environment variables. The environment variables you can customise are: 53 | 54 | | Environment variable name | Purpose | Default value | 55 | |-------------------------------|-----------------------------------------------------------------|----------------------------------------------------------------------------------------| 56 | | OVERRIDE_SLACK_MESSAGE_PREFIX | Sets a custom message prefix. | "Hello :paw_prints:, this is your friendly manual spaniel." | 57 | | OVERRIDE_SLACK_CHANNEL | Sets a single Slack channel to which all messages will be sent. | The owning Slack channel for each page reported in the site's /api/pages.json endpoint | 58 | | OVERRIDE_SLACK_USERNAME | Sets the username to which Slack messages are attributed. | "Daniel the Manual Spaniel" | 59 | | OVERRIDE_SLACK_ICON_EMOJI | Sets the icon emoji attributed to Slack messages. | ":daniel-the-manual-spaniel:" | 60 | 61 | This is an example of a customised Slack message: 62 | 63 | ![customised-message-example](docs/images/customised-message-example.png) 64 | 65 | ## Licence 66 | 67 | The gem is available as open source under the terms of the [MIT License](LICENCE). 68 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require_relative './lib/notifier' 2 | require 'chronic' 3 | 4 | task default: ["notify:expired"] 5 | 6 | namespace :notify do 7 | pages_urls = [ 8 | "https://gds-way.digital.cabinet-office.gov.uk/api/pages.json", 9 | "https://docs.payments.service.gov.uk/api/pages.json", 10 | "https://team-manual.account.gov.uk/api/pages.json", 11 | "https://dev-docs.wifi.service.gov.uk/api/pages.json", 12 | "https://docs.wifi.service.gov.uk/api/pages.json", 13 | ] 14 | 15 | limits = { 16 | } 17 | 18 | live = ENV.fetch("REALLY_POST_TO_SLACK", 0) == "1" 19 | slack_url = ENV["SLACK_WEBHOOK_URL"] 20 | slack_token = ENV["SLACK_TOKEN"] 21 | 22 | if live && (!slack_url && !slack_token) then 23 | fail "If you want to post to Slack you need to set SLACK_TOKEN or SLACK_WEBHOOK_URL" 24 | end 25 | 26 | desc "Notifies of all pages which have expired" 27 | task :expired do 28 | notification = Notification::Expired.new 29 | 30 | pages_urls.each do |page_url| 31 | puts "== #{page_url}" 32 | 33 | Notifier.new(notification, page_url, slack_url, live, limits.fetch(page_url, -1)).run 34 | end 35 | end 36 | 37 | desc "Notifies of all pages which will expire soon" 38 | task :expires, :timeframe do |_, args| 39 | args.with_defaults(timeframe: "in 1 month") 40 | expire_by = Chronic.parse(args[:timeframe]).to_date 41 | notification = Notification::WillExpireBy.new(expire_by) 42 | 43 | pages_urls.each do |page_url| 44 | puts "== #{page_url}" 45 | 46 | Notifier.new(notification, page_url, slack_url, live).run 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tech-docs-monitor", 3 | "description": "Sends Slack notifications for Tech Docs pages that need reviewing", 4 | "repository": "https://github.com/alphagov/tech-docs-monitor", 5 | "logo": "https://cdn.shopify.com/s/files/1/1061/1924/products/Dog_Emoji_large.png", 6 | "env": { 7 | "SITE_PAGE_API_URL": { 8 | "description": "The full URL to your site's `/api/pages.json` file." 9 | }, 10 | "SLACK_WEBHOOK_URL": { 11 | "description": "The Slack webhook URL to allow messages to be posted." 12 | }, 13 | "REALLY_POST_TO_SLACK": { 14 | "description": "Messages will only be posted to Slack if the value of this var is `1`.", 15 | "value": "0" 16 | } 17 | }, 18 | "addons": [ 19 | "scheduler:standard" 20 | ], 21 | "buildpacks": [ 22 | { 23 | "url": "https://github.com/heroku/heroku-buildpack-ruby.git" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /docs/images/customised-message-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/tech-docs-monitor/2b249da6d184fa79a8f87828264874a6e309391a/docs/images/customised-message-example.png -------------------------------------------------------------------------------- /docs/images/default-message-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/tech-docs-monitor/2b249da6d184fa79a8f87828264874a6e309391a/docs/images/default-message-example.png -------------------------------------------------------------------------------- /fixtures/vcr_cassettes/fresh.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://gds-way.cloudapps.digital/api/pages.json 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Connection: 11 | - close 12 | Host: 13 | - gds-way.cloudapps.digital 14 | User-Agent: 15 | - http.rb/2.1.0 16 | response: 17 | status: 18 | code: 200 19 | message: OK 20 | headers: 21 | Accept-Ranges: 22 | - bytes 23 | Content-Length: 24 | - '4585' 25 | Content-Type: 26 | - application/json 27 | Date: 28 | - Tue, 04 Sep 2018 14:01:28 GMT 29 | Etag: 30 | - '"5b89718a-11e9"' 31 | Last-Modified: 32 | - Fri, 31 Aug 2018 16:49:14 GMT 33 | Server: 34 | - nginx 35 | Vary: 36 | - Accept-Encoding 37 | X-Vcap-Request-Id: 38 | - 4c7a189d-210c-445e-4310-ea25f6943bbf 39 | Connection: 40 | - close 41 | Strict-Transport-Security: 42 | - max-age=31536000; includeSubDomains; preload 43 | body: 44 | encoding: ASCII-8BIT 45 | string: '[{"title":"How to review code","url":"https://gds-way.cloudapps.digital/manuals/code-review-guidelines.html","review_by":null,"owner_slack":"#gds-way"},{"title":"Licensing","url":"https://gds-way.cloudapps.digital/manuals/licensing.html","review_by":null,"owner_slack":"#gds-way"},{"title":"Setting 46 | up logging","url":"https://gds-way.cloudapps.digital/manuals/logging.html","review_by":null,"owner_slack":"#gds-way"},{"title":"Programming 47 | language style guides","url":"https://gds-way.cloudapps.digital/manuals/programming-languages.html","review_by":null,"owner_slack":"#gds-way"},{"title":"Java 48 | styleguide","url":"https://gds-way.cloudapps.digital/manuals/programming-languages/java.html","review_by":null,"owner_slack":"#gds-way"},{"title":"Linting 49 | Python at GDS","url":"https://gds-way.cloudapps.digital/manuals/programming-languages/python/linting.html","review_by":null,"owner_slack":"#gds-way"},{"title":"Writing 50 | Python at GDS","url":"https://gds-way.cloudapps.digital/manuals/programming-languages/python/python.html","review_by":null,"owner_slack":"#gds-way"},{"title":"GDS 51 | Python Style Guide","url":"https://gds-way.cloudapps.digital/manuals/programming-languages/python/style-guide.html","review_by":null,"owner_slack":"#gds-way"},{"title":"Ruby 52 | styleguide","url":"https://gds-way.cloudapps.digital/manuals/programming-languages/ruby.html","review_by":null,"owner_slack":"#gds-way"},{"title":"How 53 | to manage access to your third-party service accounts","url":"https://gds-way.cloudapps.digital/standards/accounts-with-third-parties.html","review_by":"2018-12-04","owner_slack":"#gds-way"},{"title":"Alerting","url":"https://gds-way.cloudapps.digital/standards/alerting.html","review_by":"2018-12-04","owner_slack":"#gds-way"},{"title":"Use 54 | configuration management","url":"https://gds-way.cloudapps.digital/standards/configuration-management.html","review_by":"2018-12-04","owner_slack":"#gds-way"},{"title":"How 55 | to manage DNS records for your service","url":"https://gds-way.cloudapps.digital/standards/dns-hosting.html","review_by":"2018-09-01","owner_slack":"#gds-way"},{"title":"How 56 | to host a service","url":"https://gds-way.cloudapps.digital/standards/hosting.html","review_by":"2018-11-01","owner_slack":"#gds-way"},{"title":"How 57 | to do penetration tests","url":"https://gds-way.cloudapps.digital/standards/how-to-do-penetration-tests.html","review_by":"2018-09-01","owner_slack":"#gds-way"},{"title":"How 58 | to store and query logs","url":"https://gds-way.cloudapps.digital/standards/logging.html","review_by":"2018-09-30","owner_slack":"#gds-way"},{"title":"How 59 | to monitor your service","url":"https://gds-way.cloudapps.digital/standards/monitoring.html","review_by":"2018-09-30","owner_slack":"#gds-way"},{"title":"How 60 | to name software products","url":"https://gds-way.cloudapps.digital/standards/naming-software-products.html","review_by":"2018-12-30","owner_slack":"#gds-way"},{"title":"Operating 61 | systems for virtual machines","url":"https://gds-way.cloudapps.digital/standards/operating-systems.html","review_by":"2018-12-04","owner_slack":"#gds-way"},{"title":"How 62 | to optimise frontend performance","url":"https://gds-way.cloudapps.digital/standards/optimise-frontend-perf.html","review_by":"2018-12-06","owner_slack":"#gds-way"},{"title":"Programming 63 | languages","url":"https://gds-way.cloudapps.digital/standards/programming-languages.html","review_by":"2018-12-04","owner_slack":"#gds-way"},{"title":"How 64 | to publish open source code","url":"https://gds-way.cloudapps.digital/standards/publish-opensource-code.html","review_by":"2018-09-01","owner_slack":"#gds-way"},{"title":"Reliability 65 | Engineering","url":"https://gds-way.cloudapps.digital/standards/reliability-engineering.html","review_by":"2018-12-01","owner_slack":"#gds-way"},{"title":"How 66 | to send email notifications","url":"https://gds-way.cloudapps.digital/standards/sending-email.html","review_by":"2018-09-01","owner_slack":"#gds-way"},{"title":"How 67 | to store source code","url":"https://gds-way.cloudapps.digital/standards/source-code.html","review_by":"2018-12-30","owner_slack":"#gds-way"},{"title":"Support 68 | Operations","url":"https://gds-way.cloudapps.digital/standards/supporting-services.html","review_by":"2018-08-01","owner_slack":"#gds-way"},{"title":"How 69 | to track technical debt","url":"https://gds-way.cloudapps.digital/standards/technical-debt.html","review_by":"2018-11-01","owner_slack":"#gds-way"},{"title":"How 70 | to manage third party software dependencies","url":"https://gds-way.cloudapps.digital/standards/tracking-dependencies.html","review_by":"2010-11-11","owner_slack":"@foobarx"}] 71 | 72 | ' 73 | http_version: 74 | recorded_at: Tue, 04 Sep 2018 14:01:24 GMT 75 | recorded_with: VCR 2.9.3 76 | -------------------------------------------------------------------------------- /lib/notification/expired.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | module Notification 4 | class Expired 5 | def include?(page) 6 | page.review_by <= Date.today 7 | end 8 | 9 | def line_for(page) 10 | age = (Date.today - page.review_by).to_i 11 | expired_when = if page.review_by == Date.today 12 | "today" 13 | elsif age == 1 14 | "yesterday" 15 | else 16 | "#{age} days ago" 17 | end 18 | "- <#{page.url}|#{page.title}> (#{expired_when})" 19 | end 20 | 21 | def singular_message 22 | "I've found a page that is due for review" 23 | end 24 | 25 | def multiple_message 26 | "I've found %s pages that are due for review" 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/notification/will_expire_by.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | module Notification 4 | class WillExpireBy 5 | def initialize(expiry_date) 6 | @expiry_date = expiry_date 7 | end 8 | 9 | def include?(page) 10 | page.review_by > Date.today && page.review_by <= @expiry_date 11 | end 12 | 13 | def line_for(page) 14 | age = (page.review_by - Date.today).to_i 15 | expires_when = if page.review_by == Date.today 16 | "today" 17 | elsif age == 1 18 | "tomorrow" 19 | else 20 | "in #{age} days" 21 | end 22 | "- <#{page.url}|#{page.title}> (#{expires_when})" 23 | end 24 | 25 | def singular_message 26 | "I've found a page that will expire on or before #{@expiry_date}" 27 | end 28 | 29 | def multiple_message 30 | "I've found %s pages that will expire on or before #{@expiry_date}" 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/notifier.rb: -------------------------------------------------------------------------------- 1 | require 'http' 2 | require 'json' 3 | require 'date' 4 | require 'uri' 5 | 6 | require_relative './page' 7 | require_relative './notification/expired' 8 | require_relative './notification/will_expire_by' 9 | 10 | class Notifier 11 | def initialize(notification, pages_url, slack_url, live, limit = -1) 12 | @notification = notification 13 | @pages_url = pages_url 14 | @slack_url = slack_url 15 | @live = !!live 16 | @limit = limit 17 | end 18 | 19 | def run 20 | payloads = message_payloads(pages_per_channel) 21 | 22 | return if payloads.empty? 23 | 24 | puts "== JSON Payload:" 25 | puts JSON.pretty_generate(payloads) 26 | 27 | if Date.today.saturday? || Date.today.sunday? 28 | puts "SKIPPING POST: Not posting anything, this is not a working day" 29 | return 30 | end 31 | 32 | unless post_to_slack? 33 | puts "SKIPPING POST: Not posting anything, this is a dry run" 34 | return 35 | end 36 | 37 | payloads.each do |message_payload| 38 | if ENV.has_key? "SLACK_TOKEN" 39 | response = HTTP 40 | .auth("Bearer #{ENV['SLACK_TOKEN']}") 41 | .post("https://slack.com/api/chat.postMessage", json: message_payload) 42 | .parse 43 | 44 | if !response["ok"] then 45 | if response["error"] == "invalid_auth" then 46 | raise "Unable to post to Slack: SLACK_TOKEN is not valid" 47 | else 48 | puts "Unable to post to Slack: #{response['error']}" 49 | end 50 | end 51 | else 52 | HTTP.post(@slack_url, body: JSON.dump(message_payload)) 53 | end 54 | end 55 | end 56 | 57 | def pages 58 | begin 59 | JSON.parse(HTTP.get(@pages_url)).map { |data| 60 | data['url'] = get_absolute_url(data['url']) 61 | Page.new(data) 62 | } 63 | rescue => exception 64 | warn "Notifier: could not get pages for tech docs at #{@pages_url}" 65 | warn exception.message 66 | return [] 67 | end 68 | end 69 | 70 | def pages_per_channel 71 | pages 72 | .reject { |page| page.review_by.nil? } 73 | .select { |page| @notification.include?(page) } 74 | .group_by { |page| page.owner } 75 | .map do |owner, pages| 76 | [owner, pages.sort_by { |page| page.review_by }] 77 | end 78 | end 79 | 80 | def message_payloads(grouped_pages) 81 | grouped_pages.map do |channel, pages| 82 | 83 | page_count = @limit == -1 ? pages.size : [pages.size, @limit].min 84 | notification_message = page_count == 1 ? @notification.singular_message : @notification.multiple_message 85 | number_of = notification_message % [page_count] 86 | 87 | page_lines = pages[0..page_count-1].map do |page| 88 | @notification.line_for(page) 89 | end 90 | 91 | message_prefix = ENV.fetch('OVERRIDE_SLACK_MESSAGE_PREFIX', "Hello :paw_prints:, this is your friendly manual spaniel.") 92 | message = <<~doc 93 | #{message_prefix} #{number_of}: 94 | 95 | #{page_lines.join("\n")} 96 | doc 97 | 98 | channel = ENV.fetch('OVERRIDE_SLACK_CHANNEL', channel) 99 | username = ENV.fetch('OVERRIDE_SLACK_USERNAME', "Daniel the Manual Spaniel") 100 | icon_emoji = ENV.fetch('OVERRIDE_SLACK_ICON_EMOJI', ":daniel-the-manual-spaniel:") 101 | 102 | puts "== Message to #{channel}" 103 | puts message 104 | 105 | { 106 | username: username, 107 | icon_emoji: icon_emoji, 108 | text: message, 109 | mrkdwn: true, 110 | channel: channel, 111 | } 112 | end 113 | end 114 | 115 | def post_to_slack? 116 | @live 117 | end 118 | 119 | private 120 | 121 | def get_absolute_url url 122 | target_uri = URI(url) 123 | target_path = Pathname.new(target_uri.path) 124 | source_uri = URI(@pages_url) 125 | 126 | if target_path.relative? 127 | resulting_path = URI::join(source_uri, target_uri.path).path 128 | else 129 | resulting_path = target_uri.path 130 | end 131 | 132 | if source_uri.scheme == 'https' 133 | URI::HTTPS.build(scheme: source_uri.scheme, port: source_uri.port, host: source_uri.host, path: resulting_path).to_s 134 | else 135 | URI::HTTP.build(scheme: source_uri.scheme, port: source_uri.port, host: source_uri.host, path: resulting_path).to_s 136 | end 137 | end 138 | end 139 | 140 | 141 | -------------------------------------------------------------------------------- /lib/page.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | class Page 4 | attr_reader :url, :title, :review_by, :owner 5 | 6 | def initialize(page_data) 7 | @url = page_data["url"] 8 | @title = page_data["title"] 9 | @review_by = page_data["review_by"].nil? ? nil : Date.parse(page_data["review_by"]) 10 | @owner = page_data["owner_slack"] 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/notifier_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative './../lib/notifier' 2 | 3 | require "spec_helper" 4 | require "vcr" 5 | require "webmock/rspec" 6 | require "timecop" 7 | 8 | # Mock notification type that includes all pages 9 | class AllPages 10 | def include?(_) 11 | true 12 | end 13 | 14 | def line_for(page) 15 | "- <#{page.url}|#{page.title}>" 16 | end 17 | 18 | def singular_message 19 | "I've found a page" 20 | end 21 | 22 | def multiple_message 23 | "I've found %s pages" 24 | end 25 | end 26 | 27 | class NoHowToPages 28 | def include?(page) 29 | !page.title.start_with? "How" 30 | end 31 | end 32 | 33 | RSpec.describe Notifier, vcr: "fresh" do 34 | before do 35 | @pages_url = "https://gds-way.cloudapps.digital/api/pages.json" 36 | Timecop.freeze(Time.local(2018, 9, 12, 0, 0, 0)) 37 | end 38 | 39 | describe "#pages" do 40 | it "builds an array of pages" do 41 | pages = Notifier.new(nil, @pages_url, nil, nil).pages 42 | pages.is_a? Array 43 | expect(pages).to all(be_a Page) 44 | end 45 | end 46 | 47 | describe "#pages_per_channel" do 48 | it "correctly groups pages" do 49 | grouped_pages = Notifier.new(AllPages.new, @pages_url, nil, nil).pages_per_channel 50 | expect(grouped_pages.length).to eq 2 51 | 52 | channel, pages = grouped_pages.first 53 | expect(channel).to eq "#gds-way" 54 | expect(pages.length).to eq 18 55 | 56 | channel, pages = grouped_pages.last 57 | expect(channel).to eq "@foobarx" 58 | expect(pages.length).to eq 1 59 | end 60 | 61 | it "correctly orders pages within groups" do 62 | grouped_pages = Notifier.new(AllPages.new, @pages_url, nil, nil).pages_per_channel 63 | _, pages = grouped_pages.first 64 | expect(pages.first.review_by).to be <= pages.last.review_by 65 | end 66 | 67 | it "filters pages as expected" do 68 | grouped_pages = Notifier.new(NoHowToPages.new, @pages_url, nil, nil).pages_per_channel 69 | _, pages = grouped_pages.first 70 | expect(pages.length).to eq 6 71 | end 72 | end 73 | 74 | context "checking for expired pages" do 75 | before do 76 | @notifier = Notifier.new(Notification::Expired.new, @pages_url, "", false) 77 | end 78 | 79 | describe "#message_payloads" do 80 | it "generates the correct message" do 81 | payloads = @notifier.message_payloads(@notifier.pages_per_channel) 82 | 83 | expect(payloads).to match([ 84 | { 85 | username: "Daniel the Manual Spaniel", 86 | icon_emoji: ":daniel-the-manual-spaniel:", 87 | text: "Hello :paw_prints:, this is your friendly manual spaniel. I've found 5 pages that are due for review:\n\n- (42 days ago)\n- (11 days ago)\n- (11 days ago)\n- (11 days ago)\n- (11 days ago)\n", 88 | mrkdwn: true, 89 | channel: "#gds-way", 90 | }, 91 | { 92 | username: "Daniel the Manual Spaniel", 93 | icon_emoji: ":daniel-the-manual-spaniel:", 94 | text: "Hello :paw_prints:, this is your friendly manual spaniel. I've found a page that is due for review:\n\n- (2862 days ago)\n", 95 | mrkdwn: true, 96 | channel: "@foobarx", 97 | } 98 | ]) 99 | end 100 | 101 | it "applies any configured overrides when generating the message" do 102 | overridden_message_prefix = "Hello :wave:, this is your friendly Docs as Code Monitor." 103 | allow(ENV).to receive(:fetch).with("OVERRIDE_SLACK_MESSAGE_PREFIX", anything) 104 | .and_return(overridden_message_prefix) 105 | 106 | overridden_slack_channel = "#team-custom-channel" 107 | allow(ENV).to receive(:fetch).with("OVERRIDE_SLACK_CHANNEL", anything) 108 | .and_return(overridden_slack_channel) 109 | 110 | overridden_slack_username = "edd.grant" 111 | allow(ENV).to receive(:fetch).with("OVERRIDE_SLACK_USERNAME", anything) 112 | .and_return(overridden_slack_username) 113 | 114 | overridden_slack_icon_emoji = ":information_source:" 115 | allow(ENV).to receive(:fetch).with("OVERRIDE_SLACK_ICON_EMOJI", anything) 116 | .and_return(overridden_slack_icon_emoji) 117 | 118 | 119 | payloads = @notifier.message_payloads(@notifier.pages_per_channel) 120 | 121 | expect(payloads).to match([ 122 | { 123 | username: overridden_slack_username, 124 | icon_emoji: overridden_slack_icon_emoji, 125 | text: "#{overridden_message_prefix} I've found 5 pages that are due for review:\n\n- (42 days ago)\n- (11 days ago)\n- (11 days ago)\n- (11 days ago)\n- (11 days ago)\n", 126 | mrkdwn: true, 127 | channel: overridden_slack_channel, 128 | }, 129 | { 130 | username: overridden_slack_username, 131 | icon_emoji: overridden_slack_icon_emoji, 132 | text: "#{overridden_message_prefix} I've found a page that is due for review:\n\n- (2862 days ago)\n", 133 | mrkdwn: true, 134 | channel: overridden_slack_channel, 135 | } 136 | ]) 137 | end 138 | 139 | it "limits the number of pages sent to slack" do 140 | limited_notifier = Notifier.new(Notification::Expired.new, @pages_url, "", false, 3) 141 | payloads = limited_notifier.message_payloads(limited_notifier.pages_per_channel) 142 | 143 | expect(payloads).to match([ 144 | { 145 | username: "Daniel the Manual Spaniel", 146 | icon_emoji: ":daniel-the-manual-spaniel:", 147 | text: "Hello :paw_prints:, this is your friendly manual spaniel. I've found 3 pages that are due for review:\n\n- (42 days ago)\n- (11 days ago)\n- (11 days ago)\n", 148 | mrkdwn: true, 149 | channel: "#gds-way", 150 | }, 151 | { 152 | username: "Daniel the Manual Spaniel", 153 | icon_emoji: ":daniel-the-manual-spaniel:", 154 | text: "Hello :paw_prints:, this is your friendly manual spaniel. I've found a page that is due for review:\n\n- (2862 days ago)\n", 155 | mrkdwn: true, 156 | channel: "@foobarx", 157 | } 158 | ]) 159 | end 160 | end 161 | end 162 | 163 | context "checking for pages nearing expiration" do 164 | before do 165 | notification = Notification::WillExpireBy.new(Date.parse("2018-10-12")) 166 | @notifier = Notifier.new(notification, @pages_url, "", false) 167 | end 168 | 169 | describe "#message_payloads" do 170 | it "generates the correct message" do 171 | payloads = @notifier.message_payloads(@notifier.pages_per_channel) 172 | 173 | expect(payloads).to match([ 174 | { 175 | username: "Daniel the Manual Spaniel", 176 | icon_emoji: ":daniel-the-manual-spaniel:", 177 | text: "Hello :paw_prints:, this is your friendly manual spaniel. I've found 2 pages that will expire on or before 2018-10-12:\n\n- (in 18 days)\n- (in 18 days)\n", 178 | mrkdwn: true, 179 | channel: "#gds-way", 180 | } 181 | ]) 182 | end 183 | 184 | it "allows overriding the slack channel" do 185 | ENV['OVERRIDE_SLACK_CHANNEL'] = '#override' 186 | payloads = @notifier.message_payloads(@notifier.pages_per_channel) 187 | 188 | expect(payloads).to match([ 189 | { 190 | username: "Daniel the Manual Spaniel", 191 | icon_emoji: ":daniel-the-manual-spaniel:", 192 | text: "Hello :paw_prints:, this is your friendly manual spaniel. I've found 2 pages that will expire on or before 2018-10-12:\n\n- (in 18 days)\n- (in 18 days)\n", 193 | mrkdwn: true, 194 | channel: "#override", 195 | } 196 | ]) 197 | end 198 | end 199 | end 200 | 201 | describe "#run" do 202 | before do 203 | ENV.delete("SLACK_TOKEN") 204 | 205 | @slack_webhook = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" 206 | @slack_api = "https://slack.com/api/chat.postMessage" 207 | 208 | VCR.configure do |config| 209 | config.ignore_hosts "slack.com", "hooks.slack.com" 210 | end 211 | end 212 | 213 | after do 214 | VCR.configure do |config| 215 | config.unignore_hosts "slack.com", "hooks.slack.com" 216 | end 217 | end 218 | 219 | it "posts to Slack" do 220 | notifier = Notifier.new(AllPages.new, @pages_url, @slack_webhook, true) 221 | notifier.run 222 | expect(a_request(:post, @slack_webhook)).to have_been_made.times(2) 223 | end 224 | 225 | it "does not post to Slack if not live" do 226 | notifier = Notifier.new(AllPages.new, @pages_url, @slack_webhook, false) 227 | notifier.run 228 | expect(a_request(:post, @slack_webhook)).not_to have_been_made 229 | end 230 | 231 | it "uses the Slack API if SLACK_TOKEN is set" do 232 | slack_token = "xoxb-xxxxxxx" 233 | stub_const("ENV", {"SLACK_TOKEN" => slack_token}) 234 | api_request = stub_request(:post, @slack_api) 235 | .to_return(body: '{"ok":true}', headers: {"Content-Type": "application/json; charset=utf-8"}) 236 | 237 | notifier = Notifier.new(AllPages.new, @pages_url, @slack_webhook, true) 238 | notifier.run 239 | 240 | # We want to use the chat.postMessage API instead of webhooks 241 | expect(a_request(:post, @slack_webhook)).not_to have_been_made 242 | expect(api_request.with(headers: {"Authorization" => "Bearer #{slack_token}"})) 243 | .to have_been_made.times(2) 244 | end 245 | 246 | it "raises an error if SLACK_TOKEN is invalid" do 247 | slack_token = "xoxb-xxxxxxx" 248 | stub_const("ENV", {"SLACK_TOKEN" => slack_token}) 249 | stub_request(:post, @slack_api) 250 | .to_return(body: '{"ok":false,"error":"invalid_auth"}', headers: {"Content-Type": "application/json; charset=utf-8"}) 251 | 252 | notifier = Notifier.new(AllPages.new, @pages_url, @slack_url, true) 253 | 254 | expect { 255 | notifier.run 256 | }.to raise_error("Unable to post to Slack: SLACK_TOKEN is not valid") 257 | end 258 | 259 | it "prints a warning if post returns error" do 260 | slack_token = "xoxb-xxxxxxx" 261 | stub_const("ENV", {"SLACK_TOKEN" => slack_token}) 262 | stub_request(:post, @slack_api) 263 | .to_return(body: '{"ok":false,"error":"channel_not_found"}', headers: {"Content-Type": "application/json; charset=utf-8"}) 264 | 265 | notifier = Notifier.new(AllPages.new, @pages_url, @slack_url, true) 266 | 267 | expect { 268 | notifier.run 269 | }.to output(/Unable to post to Slack: channel_not_found/).to_stdout 270 | end 271 | end 272 | end 273 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "vcr" 2 | 3 | VCR.configure do |config| 4 | config.cassette_library_dir = "fixtures/vcr_cassettes" 5 | config.hook_into :webmock 6 | config.configure_rspec_metadata! 7 | end 8 | 9 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 10 | RSpec.configure do |config| 11 | # rspec-expectations config goes here. You can use an alternate 12 | # assertion/expectation library such as wrong or the stdlib/minitest 13 | # assertions if you prefer. 14 | config.expect_with :rspec do |expectations| 15 | # This option will default to `true` in RSpec 4. It makes the `description` 16 | # and `failure_message` of custom matchers include text for helper methods 17 | # defined using `chain`, e.g.: 18 | # be_bigger_than(2).and_smaller_than(4).description 19 | # # => "be bigger than 2 and smaller than 4" 20 | # ...rather than: 21 | # # => "be bigger than 2" 22 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 23 | end 24 | 25 | # rspec-mocks config goes here. You can use an alternate test double 26 | # library (such as bogus or mocha) by changing the `mock_with` option here. 27 | config.mock_with :rspec do |mocks| 28 | # Prevents you from mocking or stubbing a method that does not exist on 29 | # a real object. This is generally recommended, and will default to 30 | # `true` in RSpec 4. 31 | mocks.verify_partial_doubles = true 32 | end 33 | 34 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 35 | # have no way to turn it off -- the option exists only for backwards 36 | # compatibility in RSpec 3). It causes shared context metadata to be 37 | # inherited by the metadata hash of host groups and examples, rather than 38 | # triggering implicit auto-inclusion in groups with matching metadata. 39 | config.shared_context_metadata_behavior = :apply_to_host_groups 40 | 41 | # The settings below are suggested to provide a good initial experience 42 | # with RSpec, but feel free to customize to your heart's content. 43 | =begin 44 | # This allows you to limit a spec run to individual examples or groups 45 | # you care about by tagging them with `:focus` metadata. When nothing 46 | # is tagged with `:focus`, all examples get run. RSpec also provides 47 | # aliases for `it`, `describe`, and `context` that include `:focus` 48 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 49 | config.filter_run_when_matching :focus 50 | 51 | # Allows RSpec to persist some state between runs in order to support 52 | # the `--only-failures` and `--next-failure` CLI options. We recommend 53 | # you configure your source control system to ignore this file. 54 | config.example_status_persistence_file_path = "spec/examples.txt" 55 | 56 | # Limits the available syntax to the non-monkey patched syntax that is 57 | # recommended. For more details, see: 58 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 59 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 60 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 61 | config.disable_monkey_patching! 62 | 63 | # This setting enables warnings. It's recommended, but in some cases may 64 | # be too noisy due to issues in dependencies. 65 | config.warnings = true 66 | 67 | # Many RSpec users commonly either run the entire suite or an individual 68 | # file, and it's useful to allow more verbose output when running an 69 | # individual spec file. 70 | if config.files_to_run.one? 71 | # Use the documentation formatter for detailed output, 72 | # unless a formatter has already been configured 73 | # (e.g. via a command-line flag). 74 | config.default_formatter = 'doc' 75 | end 76 | 77 | # Print the 10 slowest examples and example groups at the 78 | # end of the spec run, to help surface which specs are running 79 | # particularly slow. 80 | config.profile_examples = 10 81 | 82 | # Run specs in random order to surface order dependencies. If you find an 83 | # order dependency and want to debug it, you can fix the order by providing 84 | # the seed, which is printed after each run. 85 | # --seed 1234 86 | config.order = :random 87 | 88 | # Seed global randomization in this process using the `--seed` CLI option. 89 | # Setting this allows you to use `--seed` to deterministically reproduce 90 | # test failures related to randomization by passing the same `--seed` value 91 | # as the one that triggered the failure. 92 | Kernel.srand config.seed 93 | =end 94 | end 95 | --------------------------------------------------------------------------------