Merge lp:~pfalcon/linaro-android-build-tools/validate-build-conf into lp:linaro-android-build-tools

Proposed by Paul Sokolovsky
Status: Merged
Merged at revision: 460
Proposed branch: lp:~pfalcon/linaro-android-build-tools/validate-build-conf
Merge into: lp:linaro-android-build-tools
Diff against target: 309 lines (+237/-24)
4 files modified
node/build (+26/-22)
node/lava-submit (+1/-2)
node/prepare_build_config.py (+114/-0)
tests/test_prepare_build_config.py (+96/-0)
To merge this branch: bzr merge lp:~pfalcon/linaro-android-build-tools/validate-build-conf
Reviewer Review Type Date Requested Status
Georgy Redkozubov Approve
Paul Sokolovsky Approve
James Tunnicliffe Pending
Review via email: mp+103118@code.launchpad.net

This proposal supersedes a proposal from 2012-04-20.

Description of the change

This implements validation for build config to accommodate restricted Android builds, per teh previous discussion via email. I initially tried to implement that using bash, but it turned out to be waste of time, as various issues to workaround kept popping up. So, instead I rewrote it in Python, which should make it more robust and maintainable.

Fixed issues pointed out by Georgy, more tests.

To post a comment you must log in.
Revision history for this message
Georgy Redkozubov (gesha) wrote : Posted in a previous version of this proposal

At least following 2 errors were found:

1) convert_config_to_shell() takes 1 argument in definition, 2 was given: config = convert_config_to_shell(config_text, get_slave_type())

   Looks like you have mixed up 2 functions, please move get_slave_type() form first to second:
    config = convert_config_to_shell(config_text, get_slave_type())
    try:
        validate_config(config)

2) global name 'cfg' is not defined
     for l in cfg.split("\n"):
   You should use config_text.split("\n") instead

review: Needs Fixing
Revision history for this message
Paul Sokolovsky (pfalcon) wrote : Posted in a previous version of this proposal

Thanks, that definitely shows that more unit tests should be added ;-)

Revision history for this message
James Tunnicliffe (dooferlad) wrote :
Download full text (11.0 KiB)

Hi,

This is a good start and while I have a few comments below, they are
all for small changes that won't take long to make and should
hopefully make maintaining the code easier. I am completely behind
changing as much of the build process into Python so thanks for doing
this!

Could you run the python through the pep8 tool
(http://www.python.org/dev/peps/pep-0008/ for what it is checking).
You certainly have some lines that are too long and I think some
non-standard spacing between classes and functions. The other tool to
help reduce the number of style nags you will get is pyflakes (sudo
apt-get install pep8 pyflakes). While I think the 80 character line
length thing is a pain and being longer would be nice, it is the
standard we code to. I will happily buy you a beer and let you
complain about it at the next Connect if you want. Took me a few rants
to get over!

You could try using PyCharm as your Python editor. It provides real
time feedback on some pep8 and pyflakes errors as well as doing nice
code browsing, auto-completion, debugging and unit test running (among
many other features). We have a license.

Other comments inline.

On 23 April 2012 15:56, Paul Sokolovsky <email address hidden> wrote:

> https://code.launchpad.net/~pfalcon/linaro-android-build-tools/validate-build-conf/+merge/103118

> === added file 'node/prepare_build_config.py'
> --- node/prepare_build_config.py        1970-01-01 00:00:00 +0000
> +++ node/prepare_build_config.py        2012-04-23 14:55:21 +0000
> @@ -0,0 +1,103 @@
> +#!/usr/bin/env python
> +import sys
> +import os
> +import base64
> +import re
> +import pipes
> +
> +
> +SLAVE_TYPE_FILE = "/var/run/build-tools/slave-type"
> +SLAVE_TYPE_RESTRICTED = "Natty Release 64bit Instance Store - private builds"
> +# sf-safe build config is written to this file
> +BUILD_CONFIG_FILE = "/var/run/build-tools/build-config"
> +
> +
> +class BuildConfigMismatchException(Exception):
> +    pass
> +
> +def shell_unquote(s):
> +    # Primitive implementation, we should

Seem to be missing the end of that comment :-)

> +    if s[0] == '"' and s[-1] == '"':
> +        return s[1:-1]
> +    if s[0] == "'" and s[-1] == "'":
> +        return s[1:-1]
> +    return s

This may work better (will cope with whitespace at the beginning and
end of the line):

unquote = re.search("^\s*["'](.*?)["']\s*$", s)

if unquote:
    return unquote s.group(1)

You could do two searches instead of using the character class or a
reference so the second quote matches the first if you wanted.

> +
> +def get_slave_type():
> +    slave_type = ""
> +    try:
> +        slave_type = open(SLAVE_TYPE_FILE).read()
> +    except:
> +        pass
> +    return slave_type

This will close the file for you and avoids the need for the try/except block:

with open(SLAVE_TYPE_FILE) as slave_type_file:
    return slave_type_file.read()

return None

> +def validate_config(config, slave_type):
> +    """Validate various constraints on the config params and overall
> +    environment."""
> +    full_job_name = os.environ.get("JOB_NAME")

os.environ is a dictionary so this will return the value of "JOB_NAME"
or None. Is it worth checking for None? The next li...

452. By Paul Sokolovsky

Import optparse

453. By Paul Sokolovsky

Typo fix

454. By Paul Sokolovsky

Make sure that /var/run/build-tools exists.

455. By Paul Sokolovsky

Handle short string properly.

456. By Paul Sokolovsky

Be sure to base64-decode initial config from jenkins.

Revision history for this message
Paul Sokolovsky (pfalcon) wrote :
Download full text (10.2 KiB)

On Mon, 23 Apr 2012 16:34:17 -0000
James Tunnicliffe <email address hidden> wrote:

> Hi,
>
> This is a good start and while I have a few comments below, they are
> all for small changes that won't take long to make and should
> hopefully make maintaining the code easier. I am completely behind
> changing as much of the build process into Python so thanks for doing
> this!
>
> Could you run the python through the pep8 tool
> (http://www.python.org/dev/peps/pep-0008/ for what it is checking).
> You certainly have some lines that are too long and I think some
> non-standard spacing between classes and functions. The other tool to
> help reduce the number of style nags you will get is pyflakes (sudo
> apt-get install pep8 pyflakes). While I think the 80 character line
> length thing is a pain and being longer would be nice, it is the
> standard we code to.

I already proposed to expand it to 132 chars, don't remember anyone
objecting.

I'll run code thru pep8.

> I will happily buy you a beer and let you
> complain about it at the next Connect if you want. Took me a few rants
> to get over!
>
> You could try using PyCharm as your Python editor. It provides real
> time feedback on some pep8 and pyflakes errors as well as doing nice
> code browsing, auto-completion, debugging and unit test running (among
> many other features). We have a license.
>
> Other comments inline.
>
> On 23 April 2012 15:56, Paul Sokolovsky <email address hidden>
> wrote:
>
> > https://code.launchpad.net/~pfalcon/linaro-android-build-tools/validate-build-conf/+merge/103118
>
> > === added file 'node/prepare_build_config.py'
> > --- node/prepare_build_config.py        1970-01-01 00:00:00 +0000
> > +++ node/prepare_build_config.py        2012-04-23 14:55:21 +0000
> > @@ -0,0 +1,103 @@
> > +#!/usr/bin/env python
> > +import sys
> > +import os
> > +import base64
> > +import re
> > +import pipes
> > +
> > +
> > +SLAVE_TYPE_FILE = "/var/run/build-tools/slave-type"
> > +SLAVE_TYPE_RESTRICTED = "Natty Release 64bit Instance Store -
> > private builds" +# sf-safe build config is written to this file
> > +BUILD_CONFIG_FILE = "/var/run/build-tools/build-config"
> > +
> > +
> > +class BuildConfigMismatchException(Exception):
> > +    pass
> > +
> > +def shell_unquote(s):
> > +    # Primitive implementation, we should
>
> Seem to be missing the end of that comment :-)

Fixed.

>
> > +    if s[0] == '"' and s[-1] == '"':
> > +        return s[1:-1]
> > +    if s[0] == "'" and s[-1] == "'":
> > +        return s[1:-1]
> > +    return s
>
> This may work better (will cope with whitespace at the beginning and
> end of the line):
>
> unquote = re.search("^\s*["'](.*?)["']\s*$", s)
>
> if unquote:
> return unquote s.group(1)
>
> You could do two searches instead of using the character class or a
> reference so the second quote matches the first if you wanted.

Thanks, that code is more easy to grasp, no need to hide the primitive
impl. behind not immediately graspable regexps.

>
> > +
> > +def get_slave_type():
> > +    slave_type = ""
> > +    try:
> > +        slave_type = open(SLAVE_TYPE_FILE).read()
> > +    except:
> > +        pass
> > +    return slave_ty...

457. By Paul Sokolovsky

Fix comment

458. By Paul Sokolovsky

Close file.

Revision history for this message
Paul Sokolovsky (pfalcon) wrote :

Both pep8 --ignore=E501 and pyflakes are ok with the code now (and only minor double-line issues were before).

review: Approve
459. By Paul Sokolovsky

PEP8 double-lining.

Revision history for this message
Paul Sokolovsky (pfalcon) wrote :
460. By Paul Sokolovsky

Wrap comments a bit/fix typos.

461. By Paul Sokolovsky

Update restricted slave name

462. By Paul Sokolovsky

Remove newline from slave type file.

463. By Paul Sokolovsky

[merge] Merge in main branch

Revision history for this message
Georgy Redkozubov (gesha) wrote :

Looks good

review: Approve
Revision history for this message
Paul Sokolovsky (pfalcon) wrote :

Thanks. Verified for both normal and restricted build (https://android-build.linaro.org/builds/~linaro-android-restricted/test-buildconf-validate/#build=3), time to deploy.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'node/build'
2--- node/build 2012-02-15 17:33:38 +0000
3+++ node/build 2012-04-24 18:30:25 +0000
4@@ -21,16 +21,36 @@
5 # We need ramdisk size when executing under root, but still don't want
6 # to evaluate build config (== arbitrary code) as such.
7 function get_ramdisk_size () {
8- sudo -E -H -u jenkins-build bash -es "$1" <<\EOF
9- export CONFIGURATION="$(echo "$1" | base64 -id)"
10- set -a
11- eval "$CONFIGURATION"
12- set +a
13+ sudo -E -H -u jenkins-build bash -es <<\EOF
14+ source /var/run/build-tools/build-config
15 echo $RAMDISK_SIZE
16 EOF
17 }
18
19 BUILD_SCRIPT_ROOT=$(readlink -f "$(dirname "${0}")/../build-scripts")
20+mkdir -p /var/run/build-tools
21+if ! $BUILD_SCRIPT_ROOT/../node/prepare_build_config.py --base64 "$2"; then
22+ echo "Early exit due to build environment validation failure"
23+ exit 1
24+fi
25+# At this point, safely sourceable build config is in /var/run/build-tools/build-config
26+cat /var/run/build-tools/build-config
27+source /var/run/build-tools/build-config
28+
29+if [ -n "$BUILD_CONFIG_REPO" ]; then
30+ echo "Fetching build config indirectly from git"
31+ save_dir=$PWD
32+ rm -rf /tmp/buildconf.$$
33+ mkdir -p /tmp/buildconf.$$
34+ cd /tmp/buildconf.$$
35+ git clone "$BUILD_CONFIG_REPO"
36+ cd *
37+ git checkout "$BUILD_CONFIG_BRANCH"
38+ $BUILD_SCRIPT_ROOT/../node/prepare_build_config.py "$(cat "$BUILD_CONFIG_FILENAME")"
39+ cd $save_dir
40+ source /var/run/build-tools/build-config
41+fi
42+
43
44 # Stopgap measure to cleanup environment on instances reused for different jobs
45 mount | grep -E "^tmpfs on .+workspace/" | awk ' {print $3}' | xargs --no-run-if-empty -n1 umount
46@@ -66,26 +86,10 @@
47 sudo -E -H -u jenkins-build bash -xes "${BUILD_SCRIPT_ROOT}" "$@" <<\EOF
48 export BUILD_SCRIPT_ROOT="${1}"
49 HOST="${2}"
50-export CONFIGURATION="$(echo "${3}" | base64 -id)"
51 set -a
52-eval "$CONFIGURATION"
53+source /var/run/build-tools/build-config
54 set +a
55
56-if [ -n "$BUILD_CONFIG_REPO" ]; then
57- echo "Fetching build config indirectly from git"
58- save_dir=$PWD
59- rm -rf /tmp/buildconf.$$
60- mkdir -p /tmp/buildconf.$$
61- cd /tmp/buildconf.$$
62- git clone "$BUILD_CONFIG_REPO"
63- cd *
64- git checkout "$BUILD_CONFIG_BRANCH"
65- CONFIGURATION=$(cat "$BUILD_CONFIG_FILENAME")
66- cd $save_dir
67- set -a
68- eval $CONFIGURATION
69- set +a
70-fi
71
72 # Backward compatibility with SCRIPT_NAME
73 if [ -n "$SCRIPT_NAME" ]; then
74
75=== modified file 'node/lava-submit'
76--- node/lava-submit 2011-12-20 20:21:25 +0000
77+++ node/lava-submit 2012-04-24 18:30:25 +0000
78@@ -15,9 +15,8 @@
79 cd build
80 sudo -E -H -u jenkins-build bash -xes "${BUILD_SCRIPT_ROOT}" "$@" <<\EOF
81 export BUILD_SCRIPT_ROOT="${1}"
82-export CONFIGURATION="$(echo "${2}" | base64 -id)"
83 set -a
84-eval "$CONFIGURATION"
85+source /var/run/build-tools/build-config
86 set +a
87
88 if [ -z "$LAVA_SUBMIT" -o "$LAVA_SUBMIT" = "0" ]; then
89
90=== added file 'node/prepare_build_config.py'
91--- node/prepare_build_config.py 1970-01-01 00:00:00 +0000
92+++ node/prepare_build_config.py 2012-04-24 18:30:25 +0000
93@@ -0,0 +1,114 @@
94+#!/usr/bin/env python
95+import sys
96+import os
97+import base64
98+import re
99+import pipes
100+import optparse
101+
102+
103+SLAVE_TYPE_FILE = "/var/run/build-tools/slave-type"
104+SLAVE_TYPE_RESTRICTED = "Natty Release 64bit Instance Store - restricted builds"
105+# sf-safe build config is written to this file
106+BUILD_CONFIG_FILE = "/var/run/build-tools/build-config"
107+
108+
109+class BuildConfigMismatchException(Exception):
110+ pass
111+
112+
113+def shell_unquote(s):
114+ # Primitive implementation, but we didn't really advertize
115+ # support for shell quoting, and I never saw it used
116+ if len(s) < 2:
117+ return s
118+ if s[0] == '"' and s[-1] == '"':
119+ return s[1:-1]
120+ if s[0] == "'" and s[-1] == "'":
121+ return s[1:-1]
122+ return s
123+
124+
125+def get_slave_type():
126+ slave_type = ""
127+ try:
128+ f = open(SLAVE_TYPE_FILE)
129+ slave_type = f.read().rstrip()
130+ f.close()
131+ except:
132+ pass
133+ return slave_type
134+
135+
136+def validate_config(config, slave_type):
137+ """Validate various constraints on the config params and overall
138+ environment."""
139+ full_job_name = os.environ.get("JOB_NAME")
140+ owner, job_subname = full_job_name.split("_", 1)
141+
142+ # Deduce parameter "categories" which we can directly match
143+ # against each other
144+ if slave_type == SLAVE_TYPE_RESTRICTED:
145+ slave_type_cat = "restricted"
146+ else:
147+ slave_type_cat = "normal"
148+
149+ if owner in ["linaro-android-private", "linaro-android-restricted"]:
150+ owner_cat = "restricted"
151+ else:
152+ owner_cat = "normal"
153+
154+ if config.get("BUILD_TYPE", "build-android") in ["build-android-private", "build-android-restricted"]:
155+ build_type_cat = "restricted"
156+ else:
157+ build_type_cat = "normal"
158+
159+ # Now, process few most expected mismatches in adhoc way,
160+ # to provide better error messages
161+ if slave_type_cat == "restricted" and owner_cat != "restricted":
162+ raise BuildConfigMismatchException("Only jobs owned by ~linaro-android-restricted may run on this build slave type")
163+
164+ if owner_cat == "restricted" and build_type_cat != "restricted":
165+ raise BuildConfigMismatchException("Jobs owned by ~linaro-android-restricted must use BUILD_TYPE=build-android-restricted")
166+
167+ # Finally, generic mismatch detection
168+ if slave_type_cat != owner_cat or slave_type_cat != build_type_cat:
169+ raise BuildConfigMismatchException("Incompatible slave type, job owner and build type")
170+
171+
172+def convert_config_to_shell(config_text, out_filename):
173+ config = {}
174+ out = open(out_filename, "w")
175+
176+ for l in config_text.split("\n"):
177+ l = l.strip()
178+ if not l or l[0] == "#":
179+ continue
180+ if not re.match(r"[A-Za-z][A-Za-z0-9_]*=", l):
181+ print "Invalid build config syntax: " + l
182+ sys.exit(1)
183+ var, val = l.split("=", 1)
184+ val = shell_unquote(val)
185+ config[var] = val
186+ out.write("%s=%s\n" % (var, pipes.quote(val)))
187+ out.close()
188+ return config
189+
190+
191+def main(config_in, is_base64):
192+ if is_base64:
193+ config_in = base64.b64decode(config_in)
194+ config = convert_config_to_shell(config_in, BUILD_CONFIG_FILE)
195+ try:
196+ validate_config(config, get_slave_type())
197+ except BuildConfigMismatchException, e:
198+ print str(e)
199+ sys.exit(1)
200+
201+if __name__ == "__main__":
202+ optparser = optparse.OptionParser(usage="%prog")
203+ optparser.add_option("--base64", action="store_true", help="Process only jobs matching regex pattern")
204+ options, args = optparser.parse_args(sys.argv[1:])
205+ if len(args) != 1:
206+ optparser.error("Wrong number of arguments")
207+ main(args[0], options.base64)
208
209=== added directory 'tests'
210=== added file 'tests/test_prepare_build_config.py'
211--- tests/test_prepare_build_config.py 1970-01-01 00:00:00 +0000
212+++ tests/test_prepare_build_config.py 2012-04-24 18:30:25 +0000
213@@ -0,0 +1,96 @@
214+import sys
215+import os
216+import base64
217+import tempfile
218+sys.path.append(os.path.dirname(__file__) + "/../node")
219+import prepare_build_config
220+
221+
222+def test_validate_config():
223+ os.environ["JOB_NAME"] = "user_job"
224+ config = {}
225+ prepare_build_config.validate_config(config, "")
226+
227+ os.environ["JOB_NAME"] = "linaro-android-private_job"
228+ config = {}
229+ try:
230+ prepare_build_config.validate_config(config, "")
231+ assert False, "Mismatch wasn't caught"
232+ except prepare_build_config.BuildConfigMismatchException:
233+ pass
234+
235+ os.environ["JOB_NAME"] = "linaro-android-private_job"
236+ config = {"BUILD_TYPE": "build-android-restricted"}
237+ try:
238+ prepare_build_config.validate_config(config, "")
239+ assert False, "Mismatch wasn't caught"
240+ except prepare_build_config.BuildConfigMismatchException:
241+ pass
242+
243+ os.environ["JOB_NAME"] = "linaro-android-private_job"
244+ config = {"BUILD_TYPE": "build-android-restricted"}
245+ prepare_build_config.validate_config(config, prepare_build_config.SLAVE_TYPE_RESTRICTED)
246+
247+ os.environ["JOB_NAME"] = "linaro-android-private_job"
248+ config = {}
249+ try:
250+ prepare_build_config.validate_config(config, prepare_build_config.SLAVE_TYPE_RESTRICTED)
251+ assert False, "Mismatch wasn't caught"
252+ except prepare_build_config.BuildConfigMismatchException:
253+ pass
254+
255+ del os.environ["JOB_NAME"]
256+
257+def test_convert_config_to_shell():
258+ config_base64 = """\
259+TUFOSUZFU1RfUkVQTz1naXQ6Ly9hbmRyb2lkLmdpdC5saW5hcm8ub3JnL3BsYXRmb3JtL21hbmlm
260+ZXN0LmdpdApNQU5JRkVTVF9CUkFOQ0g9bGluYXJvX2FuZHJvaWRfMi4zLjUKTUFOSUZFU1RfRklM
261+RU5BTUU9c3RhZ2luZy1vbWFwNDQ2MC54bWwKVEFSR0VUX1BST0RVQ1Q9cGFuZGFib2FyZApUQVJH
262+RVRfU0lNVUxBVE9SPWZhbHNlClRPT0xDSEFJTl9VUkw9aHR0cHM6Ly9hbmRyb2lkLWJ1aWxkLmxp
263+bmFyby5vcmcvamVua2lucy9qb2IvbGluYXJvLWFuZHJvaWRfdG9vbGNoYWluLTQuNi1ienIvbGFz
264+dFN1Y2Nlc3NmdWxCdWlsZC9hcnRpZmFjdC9idWlsZC9vdXQvYW5kcm9pZC10b29sY2hhaW4tZWFi
265+aS00LjYtZGFpbHktbGludXgteDg2LnRhci5iejIKTEFWQV9TVUJNSVQ9MQ==
266+"""
267+ config_dict = {
268+ 'TARGET_SIMULATOR': 'false',
269+ 'TARGET_PRODUCT': 'pandaboard',
270+ 'TOOLCHAIN_URL': 'https://android-build.linaro.org/jenkins/job/linaro-android_toolchain-4.6-bzr/lastSuccessfulBuild/artifact/build/out/android-toolchain-eabi-4.6-daily-linux-x86.tar.bz2',
271+ 'MANIFEST_BRANCH': 'linaro_android_2.3.5',
272+ 'MANIFEST_FILENAME': 'staging-omap4460.xml', 'LAVA_SUBMIT': '1',
273+ 'MANIFEST_REPO': 'git://android.git.linaro.org/platform/manifest.git'
274+ }
275+
276+ config_text = base64.b64decode(config_base64)
277+ out_fd, out_filename = tempfile.mkstemp()
278+ os.close(out_fd)
279+ dict = prepare_build_config.convert_config_to_shell(config_text, out_filename)
280+ assert dict == config_dict
281+
282+def test_main():
283+ config_base64 = """\
284+TUFOSUZFU1RfUkVQTz1naXQ6Ly9hbmRyb2lkLmdpdC5saW5hcm8ub3JnL3BsYXRmb3JtL21hbmlm
285+ZXN0LmdpdApNQU5JRkVTVF9CUkFOQ0g9bGluYXJvX2FuZHJvaWRfMi4zLjUKTUFOSUZFU1RfRklM
286+RU5BTUU9c3RhZ2luZy1vbWFwNDQ2MC54bWwKVEFSR0VUX1BST0RVQ1Q9cGFuZGFib2FyZApUQVJH
287+RVRfU0lNVUxBVE9SPWZhbHNlClRPT0xDSEFJTl9VUkw9aHR0cHM6Ly9hbmRyb2lkLWJ1aWxkLmxp
288+bmFyby5vcmcvamVua2lucy9qb2IvbGluYXJvLWFuZHJvaWRfdG9vbGNoYWluLTQuNi1ienIvbGFz
289+dFN1Y2Nlc3NmdWxCdWlsZC9hcnRpZmFjdC9idWlsZC9vdXQvYW5kcm9pZC10b29sY2hhaW4tZWFi
290+aS00LjYtZGFpbHktbGludXgteDg2LnRhci5iejIKTEFWQV9TVUJNSVQ9MQ==
291+"""
292+ config_dict = {
293+ 'TARGET_SIMULATOR': 'false',
294+ 'TARGET_PRODUCT': 'pandaboard',
295+ 'TOOLCHAIN_URL': 'https://android-build.linaro.org/jenkins/job/linaro-android_toolchain-4.6-bzr/lastSuccessfulBuild/artifact/build/out/android-toolchain-eabi-4.6-daily-linux-x86.tar.bz2',
296+ 'MANIFEST_BRANCH': 'linaro_android_2.3.5',
297+ 'MANIFEST_FILENAME': 'staging-omap4460.xml', 'LAVA_SUBMIT': '1',
298+ 'MANIFEST_REPO': 'git://android.git.linaro.org/platform/manifest.git'
299+ }
300+
301+ out_fd, out_filename = tempfile.mkstemp()
302+ prepare_build_config.BUILD_CONFIG_FILE = out_filename
303+ os.environ["JOB_NAME"] = "foo_bar"
304+ prepare_build_config.main(config_base64, True)
305+ dict = {}
306+ for l in open(out_filename):
307+ var, val = l.rstrip().split("=", 1)
308+ dict[var] = val
309+ assert dict == config_dict

Subscribers

People subscribed via source and target branches