jenkins-bot has submitted this change and it was merged.
Change subject: Remote browser factories and improved docs
......................................................................
Remote browser factories and improved docs
RemoteBrowserFactory provides environment bindings for Sauce Labs API
URL and credentials and customizes the Selenium bridge used by the Watir
browser.
Change-Id: I81875d96d6d11f27ea32dd7c11dee01d3c7e4ee9
---
M .yardopts
M lib/mediawiki_selenium.rb
M lib/mediawiki_selenium/browser_factory.rb
M lib/mediawiki_selenium/browser_factory/base.rb
M lib/mediawiki_selenium/browser_factory/chrome.rb
M lib/mediawiki_selenium/browser_factory/firefox.rb
M lib/mediawiki_selenium/browser_factory/phantomjs.rb
M lib/mediawiki_selenium/environment.rb
A lib/mediawiki_selenium/remote_browser_factory.rb
M spec/browser_factory/base_spec.rb
M spec/environment_spec.rb
A spec/remote_browser_factory_spec.rb
12 files changed, 395 insertions(+), 42 deletions(-)
Approvals:
Dduvall: Looks good to me, approved
jenkins-bot: Verified
diff --git a/.yardopts b/.yardopts
index 08efff3..b2a63d4 100644
--- a/.yardopts
+++ b/.yardopts
@@ -1 +1,2 @@
--title "MediaWiki Selenium"
+--markup markdown
diff --git a/lib/mediawiki_selenium.rb b/lib/mediawiki_selenium.rb
index 2b59a4a..1236d4f 100644
--- a/lib/mediawiki_selenium.rb
+++ b/lib/mediawiki_selenium.rb
@@ -14,4 +14,5 @@
autoload :ApiHelper, "mediawiki_selenium/support/modules/api_helper"
autoload :BrowserFactory, "mediawiki_selenium/browser_factory"
autoload :Environment, "mediawiki_selenium/environment"
+ autoload :RemoteBrowserFactory, "mediawiki_selenium/remote_browser_factory"
end
diff --git a/lib/mediawiki_selenium/browser_factory.rb
b/lib/mediawiki_selenium/browser_factory.rb
index f753cd5..3fce185 100644
--- a/lib/mediawiki_selenium/browser_factory.rb
+++ b/lib/mediawiki_selenium/browser_factory.rb
@@ -1,6 +1,4 @@
module MediawikiSelenium
- # Browser factory.
- #
module BrowserFactory
autoload :Base, "mediawiki_selenium/browser_factory/base"
autoload :Firefox, "mediawiki_selenium/browser_factory/firefox"
@@ -9,17 +7,18 @@
# Resolves and instantiates a new factory for the given browser name.
#
- # @example Create a new firefox browser factory
+ # @example Create a new firefox factory
# factory = BrowserFactory.new(:firefox)
# # => #<MediawikiSelenium::BrowserFactory::Firefox>
+ # factory.browser_for(env) # => #<Watir::Browser>
#
- # @param name [Symbol] Browser name.
+ # @param browser_name [Symbol] Browser name.
#
# @return [BrowserFactory::Base]
#
- def self.new(name)
- factory_class =
const_get(name.to_s.split("_").map(&:capitalize).join(""))
- factory_class.new(name)
+ def self.new(browser_name)
+ factory_class =
const_get(browser_name.to_s.split("_").map(&:capitalize).join(""))
+ factory_class.new(browser_name)
end
end
end
diff --git a/lib/mediawiki_selenium/browser_factory/base.rb
b/lib/mediawiki_selenium/browser_factory/base.rb
index 0f739f6..248695b 100644
--- a/lib/mediawiki_selenium/browser_factory/base.rb
+++ b/lib/mediawiki_selenium/browser_factory/base.rb
@@ -2,17 +2,43 @@
module MediawikiSelenium
module BrowserFactory
+ # Browser factories instantiate browsers of a certain type, configure
+ # them according to bound environmental variables, and cache them
+ # according to the uniqueness of that configuration.
+ #
class Base
- attr_reader :type
-
class << self
- def bind(name, &blk)
+ # Binds environmental configuration to any browser created by
+ # factories of this type. Use of this method should generally be
+ # reserved for macro-style invocation in derived classes.
+ #
+ # @example Always configure Firefox's language according to
`:browser_language`
+ # module MediawikiSelenium::BrowserFactory
+ # class Firefox < Base
+ # bind(:browser_language) do |language, options|
+ #
options[:desired_capabilities][:firefox_profile]["intl.accept_languages"] =
language
+ # end
+ # end
+ # end
+ #
+ # @param names [Symbol] One or more option names.
+ #
+ # @yield [values, browser_options] A block that binds the
configuration to
+ # the browser options.
+ #
+ def bind(*names, &blk)
raise ArgumentError, "no block given" unless block_given?
- default_bindings[name] ||= []
- default_bindings[name] << blk
+ key = names.length == 1 ? names.first : names
+ default_bindings[key] ||= []
+ default_bindings[key] << blk
end
+ # All bindings for this factory class combined with those of super
+ # classes.
+ #
+ # @return [Hash]
+ #
def bindings
if superclass <= Base
default_bindings.merge(superclass.bindings) { |key, old, new| old
+ new }
@@ -21,39 +47,116 @@
end
end
+ # Bindings for this factory class.
+ #
+ # @return [Hash]
+ #
def default_bindings
@default_bindings ||= {}
end
end
+ attr_reader :browser_name
+
bind(:browser_timeout) { |value, options| options[:http_client].timeout
= value.to_i }
- def initialize(type)
- @type = type
+ # Initializes new factory instances.
+ #
+ # @param browser_name [Symbol]
+ #
+ def initialize(browser_name)
+ @browser_name = browser_name
@bindings = {}
@browser_cache = {}
end
- def bind(name, &blk)
- @bindings[name] ||= []
- @bindings[name] << (blk || proc {})
+ # Binds environmental configuration to any browser created by this
+ # factory instance.
+ #
+ # @example Override the user agent according :browser_user_agent
+ # factory = BrowserFactory.new(:firefox)
+ # factory.bind(:browser_user_agent) do |agent, options|
+ #
options[:desired_capabilities][:firefox_profile]["general.useragent.override"]
= agent
+ # end
+ #
+ # @example Annotate the session with our build information
+ # factory.bind(:job_name, :build_number) do |job, build, options|
+ # options[:desired_capabilities][:name] = "#{job} (#{build})"
+ # end
+ #
+ # @example Bindings aren't invoked unless all given options are
configured
+ # factory.bind(:foo, :bar) do |foo, bar, options|
+ # # this never happens!
+ # options[:desired_capabilities][:name] = "#{foo} #{bar}"
+ # end
+ # factory.browser_for(Environment.new(foo: "x"))
+ #
+ # @param names [Symbol] One or more option names.
+ #
+ # @yield [values, browser_options] A block that binds the configuration
to
+ # the browser options.
+ #
+ def bind(*names, &blk)
+ key = names.length == 1 ? names.first : names
+ @bindings[key] ||= []
+ @bindings[key] << (blk || proc {})
end
+ # Effective bindings for this factory, those defined at the class level
+ # and those defined for this instance.
+ #
+ # @return [Hash]
+ #
def bindings
self.class.bindings.merge(@bindings) { |key, old, new| old + new }
end
+ # Instantiate a browser using the given environmental configuration.
+ # Browsers are cached and reused as long as the *bound* configuration is
+ # the same.
+ #
+ # @example Browser is reused given the same effective configuration
+ # factory.bind(:foo) { ... }
+ # factory.bind(:bar) { ... }
+ #
+ # b1 = factory.browser_for(Environment.new(foo: "x", bar: "y"))
+ # b2 = factory.browser_for(Environment.new(bar: "x", bar: "y", baz:
"z"))
+ #
+ # b1.object_id == b2.object_id # => true
+ #
+ # @example A new browser is instantiated given different configuration
+ # factory.bind(:foo) { ... }
+ # factory.bind(:bar) { ... }
+ #
+ # b1 = factory.browser_for(Environment.new(foo: "x", bar: "y"))
+ # b2 = factory.browser_for(Environment.new(bar: "x", bar: "a"))
+ #
+ # b1.object_id == b2.object_id # => false
+ #
+ # @param env [Environment]
+ #
+ # @return [Watir::Browser]
+ #
def browser_for(env)
- config = env.lookup_all(bindings.keys)
- @browser_cache[config] ||= Watir::Browser.new(type,
browser_options(config))
+ config = env.lookup_all(bindings.keys.flatten.uniq)
+ @browser_cache[config] ||= new_browser(browser_options(config))
end
+ # Browser options for the given configuration.
+ #
+ # @param config [Hash]
+ #
+ # @return [Hash]
+ #
def browser_options(config)
- options = default_browser_options.tap do |watir_options|
- bindings.each do |(name, bindings_for_option)|
+ options = default_browser_options.tap do |default_options|
+ bindings.each do |(names, bindings_for_option)|
bindings_for_option.each do |binding|
- value = config[name]
- binding.call(value, watir_options) unless value.nil? ||
value.to_s.empty?
+ values = config.values_at(*Array(names))
+
+ unless values.any? { |value| value.nil? || value.to_s.empty? }
+ binding.call(*values, default_options)
+ end
end
end
end
@@ -66,7 +169,7 @@
protected
def desired_capabilities
- Selenium::WebDriver::Remote::Capabilities.send(type)
+ Selenium::WebDriver::Remote::Capabilities.send(browser_name)
end
def finalize_options!(options)
@@ -76,6 +179,10 @@
Selenium::WebDriver::Remote::Http::Default.new
end
+ def new_browser(options)
+ Watir::Browser.new(options[:desired_capabilities].browser_name,
options)
+ end
+
private
def default_browser_options
diff --git a/lib/mediawiki_selenium/browser_factory/chrome.rb
b/lib/mediawiki_selenium/browser_factory/chrome.rb
index ac7653a..8db6f71 100644
--- a/lib/mediawiki_selenium/browser_factory/chrome.rb
+++ b/lib/mediawiki_selenium/browser_factory/chrome.rb
@@ -1,5 +1,11 @@
module MediawikiSelenium
module BrowserFactory
+ # Constructs new Chrome browser instances. The following configuration is
+ # supported.
+ #
+ # - browser_language
+ # - browser_user_agent
+ #
class Chrome < Base
bind(:browser_language) do |language, opts|
opts[:desired_capabilities]["chromeOptions"]["profile"]["intl.accept_languages"]
= language
diff --git a/lib/mediawiki_selenium/browser_factory/firefox.rb
b/lib/mediawiki_selenium/browser_factory/firefox.rb
index b37f6f3..0c014c6 100644
--- a/lib/mediawiki_selenium/browser_factory/firefox.rb
+++ b/lib/mediawiki_selenium/browser_factory/firefox.rb
@@ -1,6 +1,14 @@
module MediawikiSelenium
module BrowserFactory
+ # Constructs new Firefox browser instances. The following configuration is
+ # supported.
+ #
+ # - browser_language
+ # - browser_timeout
+ # - browser_user_agent
+ #
class Firefox < Base
+
bind(:browser_timeout) do |timeout, opts|
timeout = timeout.to_i
opts[:desired_capabilities][:firefox_profile]["dom.max_script_run_time"] =
timeout
diff --git a/lib/mediawiki_selenium/browser_factory/phantomjs.rb
b/lib/mediawiki_selenium/browser_factory/phantomjs.rb
index 7ff1673..ec93c0a 100644
--- a/lib/mediawiki_selenium/browser_factory/phantomjs.rb
+++ b/lib/mediawiki_selenium/browser_factory/phantomjs.rb
@@ -1,5 +1,11 @@
module MediawikiSelenium
module BrowserFactory
+ # Constructs new Phantomjs browser instances. The following configuration
is
+ # supported.
+ #
+ # - browser_language
+ # - browser_user_agent
+ #
class Phantomjs < Base
bind(:browser_language) do |language, opts|
opts[:desired_capabilities]["phantomjs.page.customHeaders.Accept-Language"] =
language
diff --git a/lib/mediawiki_selenium/environment.rb
b/lib/mediawiki_selenium/environment.rb
index 339cb73..f9822d7 100644
--- a/lib/mediawiki_selenium/environment.rb
+++ b/lib/mediawiki_selenium/environment.rb
@@ -18,6 +18,7 @@
end
CORE_BROWSER_OPTIONS = [
+ :browser,
:mediawiki_url,
:mediawiki_user,
]
@@ -31,6 +32,7 @@
]
attr_reader :config
+ protected :config
def initialize(config)
@config = normalize_config(config)
@@ -46,7 +48,7 @@
#
# @param other [Environment]
#
- # @return [true, false]
+ # @return [Boolean]
#
def ==(other)
@config == other.config
@@ -69,18 +71,26 @@
# Binds new possible configuration for the given browser.
#
# @example Allow setting of Firefox's language by way of :browser_language
- # env = Environment.new(browser_language: "eo")
- # env.bind(:firefox, :browser_language) do |language, options|
- #
options[:desired_capabilities][:firefox_profile]["intl.accept_languages"] =
language
+ # Before do
+ # env.bind(:firefox, :browser_language) do |language, options|
+ #
options[:desired_capabilities][:firefox_profile]["intl.accept_languages"] =
language
+ # end
+ # end
+ #
+ # @example Annotate the session with the scenario name Jenkins job info
+ # Before do |scenario|
+ # env.bind(:firefox, :job_name, :build_number) do |job, build, options|
+ # options[:desired_capabilities][:name] = "#{scenario.name} - #{job}
##{build}"
+ # end
# end
#
# @param browser_name [Symbol] Browser name.
- # @param option_name [Symbol] Option name.
+ # @param option_names [*Symbol] Option names.
#
# @yield [value, browser_options] A block that binds the configuration to
# the browser options.
#
- def bind(browser_name, option_name, &blk)
+ def bind(browser_name, *option_names, &blk)
browser_factory(browser_name).bind(option_name, &blk)
end
@@ -94,15 +104,16 @@
# Factory used to instantiate and open new browsers.
#
- # @param type [Symbol] Browser name.
+ # @param browser [Symbol] Browser name.
#
- # @return [BrowserFactory]
+ # @return [BrowserFactory::Base]
#
- def browser_factory(type = browser_name)
- type = type.to_s.downcase.to_sym
+ def browser_factory(browser = browser_name)
+ browser = browser.to_s.downcase.to_sym
- @factory_cache[type] ||= BrowserFactory.new(type).tap do |factory|
+ @factory_cache[[remote?, browser]] ||= BrowserFactory.new(browser).tap
do |factory|
CORE_BROWSER_OPTIONS.each { |name| factory.bind(name) }
+ factory.extend(RemoteBrowserFactory) if remote?
end
end
@@ -143,7 +154,7 @@
# Returns the configured values for the given env variable names.
#
- # @param key [Array<Symbol>] Environment variable names.
+ # @param keys [Array<Symbol>] Environment variable names.
# @param id [Symbol] Alternative variable suffix.
#
# @return [Array<String>]
@@ -167,6 +178,15 @@
#
def on_wiki(id, &blk)
with_alternative([:mediawiki_url, :mediawiki_api_url], id, &blk)
+ end
+
+ # Whether this environment has been configured to use remote browser
+ # sessions.
+ #
+ # @return [Boolean]
+ #
+ def remote?
+ RemoteBrowserFactory::REQUIRED_CONFIG.all? { |name| lookup(name) }
end
# Yields a new environment using the alternative versions of the given
@@ -198,6 +218,8 @@
with(lookup_all(Array(names), id), &blk)
end
+ protected
+
private
def normalize_config(hash)
diff --git a/lib/mediawiki_selenium/remote_browser_factory.rb
b/lib/mediawiki_selenium/remote_browser_factory.rb
new file mode 100644
index 0000000..adc493c
--- /dev/null
+++ b/lib/mediawiki_selenium/remote_browser_factory.rb
@@ -0,0 +1,41 @@
+require "uri"
+
+module MediawikiSelenium
+ # Constructs remote browser sessions to be run via Sauce Labs. Adds the
+ # following configuration bindings to the factory.
+ #
+ # - sauce_ondemand_username
+ # - sauce_ondemand_access_key
+ # - platform
+ # - version
+ #
+ module RemoteBrowserFactory
+ REQUIRED_CONFIG = [:sauce_ondemand_username, :sauce_ondemand_access_key]
+ URL = "http://ondemand.saucelabs.com/wd/hub"
+
+ class << self
+ def extend_object(factory)
+ factory.bind(:sauce_ondemand_username, :sauce_ondemand_access_key) do
|user, key, options|
+ options[:url] = URI.parse(URL)
+
+ options[:url].user = user
+ options[:url].password = key
+ end
+
+ factory.bind(:platform) do |platform, options|
+ options[:desired_capabilities].platform = platform
+ end
+
+ factory.bind(:version) do |version, options|
+ options[:desired_capabilities].version = version
+ end
+ end
+ end
+
+ protected
+
+ def new_browser(options)
+ Watir::Browser.new(:remote, options)
+ end
+ end
+end
diff --git a/spec/browser_factory/base_spec.rb
b/spec/browser_factory/base_spec.rb
index e53cda5..e1148ae 100644
--- a/spec/browser_factory/base_spec.rb
+++ b/spec/browser_factory/base_spec.rb
@@ -3,8 +3,8 @@
module MediawikiSelenium::BrowserFactory
describe Base do
let(:factory_class) { Class.new(Base) }
- let(:factory) { factory_class.new(browser_type) }
- let(:browser_type) { :lynx }
+ let(:factory) { factory_class.new(browser_name) }
+ let(:browser_name) { :lynx }
describe ".bind" do
subject { factory_class.bind(option_name, &block) }
@@ -118,8 +118,10 @@
before do
factory.bind(:foo)
- expect(Selenium::WebDriver::Remote::Capabilities).to
receive(browser_type).
+ expect(Selenium::WebDriver::Remote::Capabilities).to
receive(browser_name).
at_least(:once).and_return(capabilities)
+ expect(capabilities).to receive(:browser_name).
+ at_least(:once).and_return(browser_name)
end
it "creates a new Watir::Browser" do
@@ -162,5 +164,61 @@
end
end
end
+
+ describe "#browser_options" do
+ subject { factory.browser_options(config) }
+
+ let(:config) { {} }
+
+ let(:capabilities) { double(Selenium::WebDriver::Remote::Capabilities) }
+ let(:client) { double(Selenium::WebDriver::Remote::Http::Default) }
+ let(:options) { { desired_capabilities: capabilities, http_client:
client } }
+
+ before do
+ expect(Selenium::WebDriver::Remote::Capabilities).to
receive(browser_name).
+ at_least(:once).and_return(capabilities)
+ expect(Selenium::WebDriver::Remote::Http::Default).to receive(:new).
+ and_return(client)
+ end
+
+ it { is_expected.to be_a(Hash) }
+ it { is_expected.to include(desired_capabilities: capabilities,
http_client: client) }
+
+ context "with a binding" do
+ context "and corresponding configuration" do
+ let(:config) { { foo: "x" } }
+
+ it "invokes the binding with the configured value" do
+ expect { |block| factory.bind(:foo, &block) && subject }.to
yield_with_args("x", options)
+ end
+ end
+
+ context "but no configuration" do
+ let(:config) { {} }
+
+ it "never invokes the binding" do
+ expect { |block| factory.bind(:foo, &block) && subject }.to_not
yield_control
+ end
+ end
+ end
+
+ context "with a multi-option binding" do
+ context "and complete configuration for all options" do
+ let(:config) { { foo: "x", bar: "y" } }
+
+ it "invokes the binding with the configured values" do
+ expect { |block| factory.bind(:foo, :bar, &block) && subject }.to
yield_with_args("x", "y", options)
+ end
+ end
+
+ context "but incomplete configuration for all options" do
+ let(:config) { { foo: "x" } }
+
+ it "never invokes the binding" do
+ expect { |block| factory.bind(:foo, :bar, &block) && subject
}.to_not yield_control
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/environment_spec.rb b/spec/environment_spec.rb
index bd9d341..7da0b8d 100644
--- a/spec/environment_spec.rb
+++ b/spec/environment_spec.rb
@@ -27,7 +27,7 @@
subject { env == other }
context "given an environment with the same configuration" do
- let(:other) { Environment.new(env.config) }
+ let(:other) { Environment.new(config) }
it "considers them equal" do
expect(subject).to be(true)
@@ -35,7 +35,7 @@
end
context "given an environment with different configuration" do
- let(:other) { Environment.new(env.config.merge(some: "extra")) }
+ let(:other) { Environment.new(config.merge(some: "extra")) }
it "considers them not equal" do
expect(subject).to be(false)
@@ -73,13 +73,71 @@
expect(subject.bindings).to include(*Environment::CORE_BROWSER_OPTIONS)
end
- context "given a type" do
+ context "given an explicit type of browser" do
subject { env.browser_factory(:chrome) }
it "is a factory for that type" do
expect(subject).to be_a(BrowserFactory::Chrome)
end
end
+
+ context "caching in a cloned environment" do
+ let(:env1) { env }
+ let(:env2) { env1.clone }
+
+ let(:factory1) { env.browser_factory(browser1) }
+ let(:factory2) { env2.browser_factory(browser2) }
+
+ context "with the same local/remote behavior as before" do
+ before do
+ expect(env1).to receive(:remote?).at_least(:once).and_return(false)
+ expect(env2).to receive(:remote?).at_least(:once).and_return(false)
+ end
+
+ context "and the same type of browser as before" do
+ let(:browser1) { :firefox }
+ let(:browser2) { :firefox }
+
+ it "returns a cached factory" do
+ expect(factory1).to be(factory2)
+ end
+ end
+
+ context "and a different type of browser than before" do
+ let(:browser1) { :firefox }
+ let(:browser2) { :chrome }
+
+ it "returns a new factory" do
+ expect(factory1).not_to be(factory2)
+ end
+ end
+ end
+
+ context "with different local/remote behavior as before" do
+ before do
+ expect(env1).to receive(:remote?).at_least(:once).and_return(false)
+ expect(env2).to receive(:remote?).at_least(:once).and_return(true)
+ end
+
+ context "but the same type of browser as before" do
+ let(:browser1) { :firefox }
+ let(:browser2) { :firefox }
+
+ it "returns a cached factory" do
+ expect(factory1).not_to be(factory2)
+ end
+ end
+
+ context "and a different type of browser than before" do
+ let(:browser1) { :firefox }
+ let(:browser2) { :chrome }
+
+ it "returns a new factory" do
+ expect(factory1).not_to be(factory2)
+ end
+ end
+ end
+ end
end
describe "#browser_name" do
diff --git a/spec/remote_browser_factory_spec.rb
b/spec/remote_browser_factory_spec.rb
new file mode 100644
index 0000000..963a545
--- /dev/null
+++ b/spec/remote_browser_factory_spec.rb
@@ -0,0 +1,46 @@
+require "spec_helper"
+require "mediawiki_selenium/browser_factory/base"
+
+module MediawikiSelenium
+ describe RemoteBrowserFactory do
+ let(:factory_class) { Class.new(BrowserFactory::Base) }
+ let(:factory) { factory_class.new(:foo).extend(RemoteBrowserFactory) }
+
+ describe "#browser_options" do
+ subject { factory.browser_options(config) }
+
+ let(:config) { {} }
+ let(:capabilities) { double(Selenium::WebDriver::Remote::Capabilities) }
+
+ before do
+ expect(Selenium::WebDriver::Remote::Capabilities).to
receive(:foo).and_return(capabilities)
+ end
+
+ context "given a sauce_ondemand_username and sauce_ondemand_access_key"
do
+ let(:config) { { sauce_ondemand_username: "foo",
sauce_ondemand_access_key: "bar" } }
+
+ it "configures the remote webdriver url" do
+ expect(subject[:url]).to
eq(URI.parse("http://foo:[email protected]/wd/hub"))
+ end
+ end
+
+ context "given a browser platform" do
+ let(:config) { { platform: "foo" } }
+
+ it "configures the browser platform" do
+ expect(capabilities).to receive(:platform=).with("foo")
+ subject
+ end
+ end
+
+ context "given a browser version" do
+ let(:config) { { version: "123" } }
+
+ it "configures the browser version" do
+ expect(capabilities).to receive(:version=).with("123")
+ subject
+ end
+ end
+ end
+ end
+end
--
To view, visit https://gerrit.wikimedia.org/r/169854
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: merged
Gerrit-Change-Id: I81875d96d6d11f27ea32dd7c11dee01d3c7e4ee9
Gerrit-PatchSet: 2
Gerrit-Project: mediawiki/selenium
Gerrit-Branch: env-abstraction-layer
Gerrit-Owner: Dduvall <[email protected]>
Gerrit-Reviewer: Dduvall <[email protected]>
Gerrit-Reviewer: jenkins-bot <>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits