Merge lp:~zyga/checkbox/result-history into lp:checkbox

Proposed by Zygmunt Krynicki
Status: Merged
Approved by: Sylvain Pineau
Approved revision: 3824
Merged at revision: 3816
Proposed branch: lp:~zyga/checkbox/result-history
Merge into: lp:checkbox
Diff against target: 585 lines (+157/-30)
12 files modified
checkbox-ng/checkbox_ng/service.py (+1/-1)
plainbox/plainbox/data/report/checkbox.html (+25/-0)
plainbox/plainbox/impl/exporter/test_text.py (+1/-1)
plainbox/plainbox/impl/exporter/text.py (+17/-3)
plainbox/plainbox/impl/session/jobs.py (+59/-13)
plainbox/plainbox/impl/session/resume.py (+4/-7)
plainbox/plainbox/impl/session/suspend.py (+4/-5)
plainbox/plainbox/impl/session/test_jobs.py (+11/-0)
plainbox/plainbox/test-data/html-exporter/with_both_certification_status.html (+14/-0)
plainbox/plainbox/test-data/html-exporter/with_certification_blocker.html (+7/-0)
plainbox/plainbox/test-data/html-exporter/with_certification_non_blocker.html (+7/-0)
plainbox/plainbox/test-data/html-exporter/without_certification_status.html (+7/-0)
To merge this branch: bzr merge lp:~zyga/checkbox/result-history
Reviewer Review Type Date Requested Status
Sylvain Pineau Approve
Review via email: mp+260457@code.launchpad.net

Description of the change

56b7b43 checkbox-ng:service: fix copy-paste error
17c2aea plainbox:session:jobs: fix PEP257 issues
f23ccc2 plainbox:session:jobs: keep track of result history
284f0b8 plainbox:session:suspend: fix PEP257 issue
98647a1 plainbox:session:suspend: store full result history
d2282fb plainbox:session:result: replay the full result history
19e21e2 plainbox:exporter:text: fix PEP257 issue
8b389d0 plainbox:exporter:text: display result history
3f0f87d plainbox:exporter:html: display result history

To post a comment you must log in.
lp:~zyga/checkbox/result-history updated
3823. By Zygmunt Krynicki

plainbox:exporter:text: display result history

Signed-off-by: Zygmunt Krynicki <email address hidden>

3824. By Zygmunt Krynicki

plainbox:exporter:html: display result history

Signed-off-by: Zygmunt Krynicki <email address hidden>

Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

tested with both gui and cli using the smoke testplan. all the results are available in the report.

+1

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'checkbox-ng/checkbox_ng/service.py'
2--- checkbox-ng/checkbox_ng/service.py 2015-05-28 10:04:37 +0000
3+++ checkbox-ng/checkbox_ng/service.py 2015-05-28 11:25:44 +0000
4@@ -528,7 +528,7 @@
5 return self.native.comments or ""
6
7 @comments.setter
8- def comments(self, value):
9+ def comments(self, new_value):
10 """
11 set comments to a new value
12 """
13
14=== modified file 'plainbox/plainbox/data/report/checkbox.html'
15--- plainbox/plainbox/data/report/checkbox.html 2015-05-19 17:56:35 +0000
16+++ plainbox/plainbox/data/report/checkbox.html 2015-05-28 11:25:44 +0000
17@@ -127,6 +127,9 @@
18 font-size: 10px;
19 line-height: 14px;
20 }
21+ tr.historic-run td:first-child {
22+ padding-left: 2em;
23+ }
24 .data {
25 display: none;
26 }
27@@ -217,6 +220,7 @@
28 <th>Test ID</th>
29 <th>Result</th>
30 <th>Certification status</th>
31+ <th>Run</th>
32 <th>Comment</th>
33 </tr>
34 </thead>
35@@ -226,6 +230,7 @@
36 <td>{{ job_state.job.tr_summary() }}</td>
37 <td style='font-weight: bold; color: {{ job_state.result.outcome_meta().color_hex }}'>{{ job_state.result.outcome_meta().tr_label }}</td>
38 <td>{{ job_state.effective_certification_status }}</td>
39+ <td>{{ job_state.result_history|length }}</td>
40 {%- if job_state.result.comments != None %}
41 <td>{{ job_state.result.comments }}</td>
42 {%- else %}
43@@ -239,6 +244,26 @@
44 {%- endif %}
45 {%- endif %}
46 </tr>
47+ {%- for result in job_state.result_history[:-1] %}
48+ <tr class='historic-run'>
49+ <td>{{ job_state.job.tr_summary() }}</td>
50+ <td style='font-weight: bold; color: {{ result.outcome_meta().color_hex }}'>{{ result.outcome_meta().tr_label }}</td>
51+ <td></td>
52+ <td>{{ loop.index }}</td>
53+ {%- if result.comments != None %}
54+ <td>{{ result.comments }}</td>
55+ {%- else %}
56+ {%- if result.io_log_as_flat_text != "" %}
57+ <td><div style="vertical-align: middle; width: 420px; overflow: auto;">
58+ <pre>{{ result.io_log_as_flat_text }}</pre>
59+ </div>
60+ </td>
61+ {%- else %}
62+ <td>&nbsp;</td>
63+ {%- endif %}
64+ {%- endif %}
65+ </tr>
66+ {%- endfor %}
67 {%- endfor %}
68 </tbody>
69 </table>
70
71=== modified file 'plainbox/plainbox/impl/exporter/test_text.py'
72--- plainbox/plainbox/impl/exporter/test_text.py 2014-11-11 06:06:55 +0000
73+++ plainbox/plainbox/impl/exporter/test_text.py 2015-05-28 11:25:44 +0000
74@@ -44,7 +44,7 @@
75 data = mock.Mock(
76 run_list=[job],
77 job_state_map={
78- job.id: mock.Mock(result=result, job=job)
79+ job.id: mock.Mock(result=result, job=job, result_history=())
80 }
81 )
82 stream = BytesIO()
83
84=== modified file 'plainbox/plainbox/impl/exporter/text.py'
85--- plainbox/plainbox/impl/exporter/text.py 2015-03-31 08:38:35 +0000
86+++ plainbox/plainbox/impl/exporter/text.py 2015-05-28 11:25:44 +0000
87@@ -25,15 +25,15 @@
88
89 THIS MODULE DOES NOT HAVE STABLE PUBLIC API
90 """
91+from plainbox.i18n import gettext as _
92 from plainbox.impl.commands.inv_run import Colorizer
93 from plainbox.impl.exporter import SessionStateExporterBase
94 from plainbox.impl.result import outcome_meta
95
96
97 class TextSessionStateExporter(SessionStateExporterBase):
98- """
99- Human-readable session state exporter.
100- """
101+
102+ """Human-readable session state exporter."""
103
104 def __init__(self, option_list=None, color=None):
105 super().__init__(option_list)
106@@ -55,9 +55,23 @@
107 outcome_meta(state.result.outcome).color_ansi
108 ), state.job.tr_summary(),
109 ).encode("UTF-8"))
110+ if len(state.result_history) > 1:
111+ stream.write(_(" history: {0}\n").format(
112+ ', '.join(
113+ self.C.custom(
114+ result.outcome_meta().tr_outcome,
115+ result.outcome_meta().color_ansi)
116+ for result in state.result_history)
117+ ).encode("UTF-8"))
118 else:
119 stream.write(
120 "{:^15}: {}\n".format(
121 state.result.tr_outcome(),
122 state.job.tr_summary(),
123 ).encode("UTF-8"))
124+ if state.result_history:
125+ print(_("History:"), ', '.join(
126+ self.C.custom(
127+ result.outcome_meta().unicode_sigil,
128+ result.outcome_meta().color_ansi)
129+ for result in state.result_history))
130
131=== modified file 'plainbox/plainbox/impl/session/jobs.py'
132--- plainbox/plainbox/impl/session/jobs.py 2015-04-09 13:20:17 +0000
133+++ plainbox/plainbox/impl/session/jobs.py 2015-05-28 11:25:44 +0000
134@@ -16,6 +16,8 @@
135 # You should have received a copy of the GNU General Public License
136 # along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
137 """
138+Job State.
139+
140 :mod:`plainbox.impl.session.jobs` -- jobs state handling
141 ========================================================
142
143@@ -39,8 +41,9 @@
144
145
146 class InhibitionCause(IntEnum):
147+
148 """
149- There are four possible not-ready causes:
150+ There are four possible not-ready causes.
151
152 UNDESIRED:
153 This job was not selected to run in this session
154@@ -58,6 +61,7 @@
155 FAILED_RESOURCE:
156 This job has a resource requirement that evaluated to a false value
157 """
158+
159 UNDESIRED = 0
160 PENDING_DEP = 1
161 FAILED_DEP = 2
162@@ -68,6 +72,8 @@
163 def cause_convert_assign_filter(
164 instance: pod.POD, field: pod.Field, old: "Any", new: "Any") -> "Any":
165 """
166+ Assign filter for for JobReadinessInhibitor.cause.
167+
168 Custom assign filter for the JobReadinessInhibitor.cause field that
169 produces a very specific error message.
170 """
171@@ -78,6 +84,7 @@
172
173
174 class JobReadinessInhibitor(pod.POD):
175+
176 """
177 Class representing the cause of a job not being ready to execute.
178
179@@ -122,6 +129,7 @@
180 Provides additional context for the problem caused by a failing
181 resource expression.
182 """
183+
184 # XXX: PENDING_RESOURCE is not strict, there are multiple states that are
185 # clumped here which is something I don't like. A resource may be still
186 # "pending" as in PENDING_DEP (it has not ran yet) or it could have ran but
187@@ -176,11 +184,13 @@
188 ).format(self.cause.name))
189
190 def __repr__(self):
191+ """Get a custom debugging representation of an inhibitor."""
192 return "<{} cause:{} related_job:{!r} related_expression:{!r}>".format(
193 self.__class__.__name__, self.cause.name, self.related_job,
194 self.related_expression)
195
196 def __str__(self):
197+ """Get a human-readable text representation of an inhibitor."""
198 if self.cause == InhibitionCause.UNDESIRED:
199 # TRANSLATORS: as in undesired job
200 return _("undesired")
201@@ -211,7 +221,10 @@
202
203
204 class OverridableJobField(pod.Field):
205+
206 """
207+ A custom Field for modeling job values that can be overridden.
208+
209 A readable-writable field that has a special initial value ``JOB_VALUE``
210 which is interpreted as "load this value from the corresponding job
211 definition".
212@@ -222,11 +235,13 @@
213
214 def __init__(self, job_field, doc=None, type=None, notify=False,
215 assign_filter_list=None):
216+ """Initialize a new overridable job field."""
217 super().__init__(
218 doc, type, JOB_VALUE, None, notify, assign_filter_list)
219 self.job_field = job_field
220
221 def __get__(self, instance, owner):
222+ """Get an overriden (if any) value of an overridable job field."""
223 value = super().__get__(instance, owner)
224 if value is JOB_VALUE:
225 return getattr(instance.job, self.job_field)
226@@ -235,22 +250,28 @@
227
228
229 def job_assign_filter(instance, field, old_value, new_value):
230- # FIXME: This setter should not exist. job attribute should be
231- # read-only. This is a temporary kludge to get session restoring
232- # over DBus working. Once a solution that doesn't involve setting
233- # a JobState's job attribute is implemented, please remove this
234- # awful method.
235+ """
236+ A custom setter for the JobState.job.
237+
238+ .. warning::
239+ This setter should not exist. job attribute should be read-only. This
240+ is a temporary kludge to get session restoring over DBus working. Once
241+ a solution that doesn't involve setting a JobState's job attribute is
242+ implemented, please remove this awful method.
243+ """
244 return new_value
245
246
247 def job_via_assign_filter(instance, field, old_value, new_value):
248+ """A custom setter for JobState.via_job."""
249 if (old_value is not pod.UNSET and not isinstance(new_value, JobDefinition)
250- and not new_value is None):
251+ and new_value is not None):
252 raise TypeError("via_job must be the actual job, not the checksum")
253 return new_value
254
255
256 class JobState(pod.POD):
257+
258 """
259 Class representing the state of a job in a session.
260
261@@ -284,6 +305,11 @@
262 initial_fn=lambda: MemoryJobResult({}),
263 notify=True)
264
265+ result_history = pod.Field(
266+ doc="a tuple of result_history of the associated job",
267+ type=tuple, initial=(), notify=True,
268+ assign_filter_list=[pod.typed, pod.typed.sequence(IJobResult)])
269+
270 via_job = pod.Field(
271 doc="the parent job definition",
272 type=JobDefinition,
273@@ -299,16 +325,36 @@
274 doc="the effective certification status of this job",
275 type=str)
276
277+ # NOTE: the `result` property just exposes the last result from the
278+ # `result_history` tuple above. The API is used everywhere so it should not
279+ # be broken in any way but the way forward is the sequence stored in
280+ # `result_history`.
281+ #
282+ # The one particularly annoying part of this implementation is that each
283+ # job state always has at least one result. Even if there was no testing
284+ # done yet. This OUTCOME_NONE result needs to be filtered out at various
285+ # times. I think it would be better if we could not have it in the
286+ # sequence-based API anymore. Otherwise each test will have two
287+ # result_history (more if you count things like resuming a session).
288+
289+ @result.change_notifier
290+ def _result_changed(self, old, new):
291+ # Don't track the initial assignment over UNSET
292+ if old is pod.UNSET:
293+ return
294+ assert new != old
295+ assert isinstance(new, IJobResult)
296+ if new.is_hollow:
297+ return
298+ logger.debug("Appending result %r to history: %r", new, self.result_history)
299+ self.result_history += (new,)
300+
301 def can_start(self):
302- """
303- Quickly check if the associated job can run right now.
304- """
305+ """Quickly check if the associated job can run right now."""
306 return len(self.readiness_inhibitor_list) == 0
307
308 def get_readiness_description(self):
309- """
310- Get a human readable description of the current readiness state
311- """
312+ """Get a human readable description of the current readiness state."""
313 if self.readiness_inhibitor_list:
314 return _("job cannot be started: {}").format(
315 ", ".join((str(inhibitor)
316
317=== modified file 'plainbox/plainbox/impl/session/resume.py'
318--- plainbox/plainbox/impl/session/resume.py 2015-04-13 18:20:44 +0000
319+++ plainbox/plainbox/impl/session/resume.py 2015-05-28 11:25:44 +0000
320@@ -707,13 +707,10 @@
321 result = self._build_JobResult(
322 result_repr, self.flags, self.location)
323 result_list.append(result)
324- # Show the _LAST_ result to the session. Currently we only store one
325- # result but showing the most recent (last) result should be good
326- # in general.
327- if len(result_list) > 0:
328- logger.debug(
329- _("calling update_job_result(%r, %r)"), job, result_list[-1])
330- session.update_job_result(job, result_list[-1])
331+ # Replay each result, one by one
332+ for result in result_list:
333+ logger.debug(_("calling update_job_result(%r, %r)"), job, result)
334+ session.update_job_result(job, result)
335
336 @classmethod
337 def _restore_SessionState_desired_job_list(cls, session, session_repr):
338
339=== modified file 'plainbox/plainbox/impl/session/suspend.py'
340--- plainbox/plainbox/impl/session/suspend.py 2015-04-13 13:58:00 +0000
341+++ plainbox/plainbox/impl/session/suspend.py 2015-05-28 11:25:44 +0000
342@@ -238,7 +238,7 @@
343 }
344
345 def _repr_JobResult(self, obj, session_dir):
346- """ Compute the representation of one of IJobResult subclasses. """
347+ """Compute the representation of one of IJobResult subclasses."""
348 if isinstance(obj, DiskJobResult):
349 return self._repr_DiskJobResult(obj, session_dir)
350 elif isinstance(obj, MemoryJobResult):
351@@ -510,11 +510,10 @@
352 if not state.result.is_hollow or state.job.id in id_run_list
353 },
354 "results": {
355- # Currently we store only one result but we may store
356- # more than that in a later version.
357- state.job.id: [self._repr_JobResult(state.result, session_dir)]
358+ state.job.id: [self._repr_JobResult(result, session_dir)
359+ for result in state.result_history]
360 for state in obj.job_state_map.values()
361- if not state.result.is_hollow
362+ if len(state.result_history) > 0
363 },
364 "desired_job_list": [
365 job.id for job in obj.desired_job_list
366
367=== modified file 'plainbox/plainbox/impl/session/test_jobs.py'
368--- plainbox/plainbox/impl/session/test_jobs.py 2015-04-09 13:20:17 +0000
369+++ plainbox/plainbox/impl/session/test_jobs.py 2015-05-28 11:25:44 +0000
370@@ -140,6 +140,7 @@
371 def test_smoke(self):
372 self.assertIsNotNone(self.job_state.result)
373 self.assertIs(self.job_state.result.outcome, IJobResult.OUTCOME_NONE)
374+ self.assertEqual(self.job_state.result_history, ())
375 self.assertEqual(self.job_state.readiness_inhibitor_list, [
376 UndesiredJobReadinessInhibitor])
377 self.assertEqual(self.job_state.effective_category_id,
378@@ -164,6 +165,16 @@
379 self.job_state.result = result
380 self.assertIs(self.job_state.result, result)
381
382+ def test_result_history_keeps_track_of_result_changes(self):
383+ # XXX: this example will fail if subsequent results are identical
384+ self.assertEqual(self.job_state.result_history, ())
385+ result1 = make_job_result(outcome='fail')
386+ self.job_state.result = result1
387+ self.assertEqual(self.job_state.result_history, (result1,))
388+ result2 = make_job_result(outcome='pass')
389+ self.job_state.result = result2
390+ self.assertEqual(self.job_state.result_history, (result1, result2))
391+
392 def test_setting_result_fires_signal(self):
393 """
394 verify that assigning state.result fires the on_result_changed signal
395
396=== modified file 'plainbox/plainbox/test-data/html-exporter/with_both_certification_status.html'
397--- plainbox/plainbox/test-data/html-exporter/with_both_certification_status.html 2015-05-19 17:56:35 +0000
398+++ plainbox/plainbox/test-data/html-exporter/with_both_certification_status.html 2015-05-28 11:25:44 +0000
399@@ -123,6 +123,9 @@
400 font-size: 10px;
401 line-height: 14px;
402 }
403+ tr.historic-run td:first-child {
404+ padding-left: 2em;
405+ }
406 .data {
407 display: none;
408 }
409@@ -203,6 +206,7 @@
410 <th>Test ID</th>
411 <th>Result</th>
412 <th>Certification status</th>
413+ <th>Run</th>
414 <th>Comment</th>
415 </tr>
416 </thead>
417@@ -211,6 +215,7 @@
418 <td>job 1</td>
419 <td style='font-weight: bold; color: #DC3912'>failed</td>
420 <td>blocker</td>
421+ <td>1</td>
422 <td><div style="vertical-align: middle; width: 420px; overflow: auto;">
423 <pre>FATAL ERROR
424 </pre>
425@@ -221,16 +226,25 @@
426 <td>job 2</td>
427 <td style='font-weight: bold; color: #DC3912'>failed</td>
428 <td>non-blocker</td>
429+ <td>2</td>
430 <td><div style="vertical-align: middle; width: 420px; overflow: auto;">
431 <pre>FATAL ERROR
432 </pre>
433 </div>
434 </td>
435 </tr>
436+ <tr class='historic-run'>
437+ <td>job 2</td>
438+ <td style='font-weight: bold; color: #6AA84F'>passed</td>
439+ <td></td>
440+ <td>1</td>
441+ <td>blah blah</td>
442+ </tr>
443 <tr>
444 <td>job 3</td>
445 <td style='font-weight: bold; color: #FF9900'>skipped</td>
446 <td>unspecified</td>
447+ <td>1</td>
448 <td>No such device</td>
449 </tr>
450 </tbody>
451
452=== modified file 'plainbox/plainbox/test-data/html-exporter/with_certification_blocker.html'
453--- plainbox/plainbox/test-data/html-exporter/with_certification_blocker.html 2015-05-19 17:56:35 +0000
454+++ plainbox/plainbox/test-data/html-exporter/with_certification_blocker.html 2015-05-28 11:25:44 +0000
455@@ -123,6 +123,9 @@
456 font-size: 10px;
457 line-height: 14px;
458 }
459+ tr.historic-run td:first-child {
460+ padding-left: 2em;
461+ }
462 .data {
463 display: none;
464 }
465@@ -186,6 +189,7 @@
466 <th>Test ID</th>
467 <th>Result</th>
468 <th>Certification status</th>
469+ <th>Run</th>
470 <th>Comment</th>
471 </tr>
472 </thead>
473@@ -194,6 +198,7 @@
474 <td>job 1</td>
475 <td style='font-weight: bold; color: #DC3912'>failed</td>
476 <td>blocker</td>
477+ <td>1</td>
478 <td><div style="vertical-align: middle; width: 420px; overflow: auto;">
479 <pre>FATAL ERROR
480 </pre>
481@@ -204,12 +209,14 @@
482 <td>job 2</td>
483 <td style='font-weight: bold; color: #6AA84F'>passed</td>
484 <td>unspecified</td>
485+ <td>1</td>
486 <td>blah blah</td>
487 </tr>
488 <tr>
489 <td>job 3</td>
490 <td style='font-weight: bold; color: #FF9900'>skipped</td>
491 <td>unspecified</td>
492+ <td>1</td>
493 <td>No such device</td>
494 </tr>
495 </tbody>
496
497=== modified file 'plainbox/plainbox/test-data/html-exporter/with_certification_non_blocker.html'
498--- plainbox/plainbox/test-data/html-exporter/with_certification_non_blocker.html 2015-05-19 17:56:35 +0000
499+++ plainbox/plainbox/test-data/html-exporter/with_certification_non_blocker.html 2015-05-28 11:25:44 +0000
500@@ -123,6 +123,9 @@
501 font-size: 10px;
502 line-height: 14px;
503 }
504+ tr.historic-run td:first-child {
505+ padding-left: 2em;
506+ }
507 .data {
508 display: none;
509 }
510@@ -184,6 +187,7 @@
511 <th>Test ID</th>
512 <th>Result</th>
513 <th>Certification status</th>
514+ <th>Run</th>
515 <th>Comment</th>
516 </tr>
517 </thead>
518@@ -192,6 +196,7 @@
519 <td>job 1</td>
520 <td style='font-weight: bold; color: #DC3912'>failed</td>
521 <td>non-blocker</td>
522+ <td>1</td>
523 <td><div style="vertical-align: middle; width: 420px; overflow: auto;">
524 <pre>FATAL ERROR
525 </pre>
526@@ -202,12 +207,14 @@
527 <td>job 2</td>
528 <td style='font-weight: bold; color: #6AA84F'>passed</td>
529 <td>unspecified</td>
530+ <td>1</td>
531 <td>blah blah</td>
532 </tr>
533 <tr>
534 <td>job 3</td>
535 <td style='font-weight: bold; color: #FF9900'>skipped</td>
536 <td>unspecified</td>
537+ <td>1</td>
538 <td>No such device</td>
539 </tr>
540 </tbody>
541
542=== modified file 'plainbox/plainbox/test-data/html-exporter/without_certification_status.html'
543--- plainbox/plainbox/test-data/html-exporter/without_certification_status.html 2015-05-19 17:56:35 +0000
544+++ plainbox/plainbox/test-data/html-exporter/without_certification_status.html 2015-05-28 11:25:44 +0000
545@@ -123,6 +123,9 @@
546 font-size: 10px;
547 line-height: 14px;
548 }
549+ tr.historic-run td:first-child {
550+ padding-left: 2em;
551+ }
552 .data {
553 display: none;
554 }
555@@ -167,6 +170,7 @@
556 <th>Test ID</th>
557 <th>Result</th>
558 <th>Certification status</th>
559+ <th>Run</th>
560 <th>Comment</th>
561 </tr>
562 </thead>
563@@ -175,6 +179,7 @@
564 <td>job 1</td>
565 <td style='font-weight: bold; color: #DC3912'>failed</td>
566 <td>unspecified</td>
567+ <td>1</td>
568 <td><div style="vertical-align: middle; width: 420px; overflow: auto;">
569 <pre>FATAL ERROR
570 </pre>
571@@ -185,12 +190,14 @@
572 <td>job 2</td>
573 <td style='font-weight: bold; color: #6AA84F'>passed</td>
574 <td>unspecified</td>
575+ <td>1</td>
576 <td>blah blah</td>
577 </tr>
578 <tr>
579 <td>job 3</td>
580 <td style='font-weight: bold; color: #FF9900'>skipped</td>
581 <td>unspecified</td>
582+ <td>1</td>
583 <td>No such device</td>
584 </tr>
585 </tbody>

Subscribers

People subscribed via source and target branches