Merge ~pelpsi/launchpad:snap-component-integration into launchpad:master

Proposed by Simone Pelosi
Status: Merged
Approved by: Simone Pelosi
Approved revision: 21ff0a40b83935aee230066ffa104dff5802cd4c
Merged at revision: 9176ff43d948b8a895c2b2689721dbb16415e9c9
Proposed branch: ~pelpsi/launchpad:snap-component-integration
Merge into: launchpad:master
Diff against target: 527 lines (+343/-15)
4 files modified
lib/lp/snappy/interfaces/snapbuildjob.py (+12/-1)
lib/lp/snappy/model/snapbuildjob.py (+56/-1)
lib/lp/snappy/model/snapstoreclient.py (+7/-3)
lib/lp/snappy/tests/test_snapbuildjob.py (+268/-10)
Reviewer Review Type Date Requested Status
William Grant code Approve
Ines Almeida Abstain
Review via email: mp+461063@code.launchpad.net

Commit message

Add Snap Component support

Reference: LP142
Snap Components are processed and uploaded to the
snapcraft storage before pushing the Snap to the store.
Once every component is updated on the storage we can push
the Snap to the store.
push` function changed to support the new `components` parameter.

To post a comment you must log in.
Revision history for this message
Ines Almeida (ines-almeida) wrote :

Great work!
I added some questions and some comments that need addressing.
Apologies for taking so long to review this MP

review: Needs Information
Revision history for this message
Simone Pelosi (pelpsi) wrote :

Thank you Ines for your precious review! I addressed your comments and I replied to the ones that I didn't addressed with the reason :)

Revision history for this message
William Grant (wgrant) :
Revision history for this message
Ines Almeida (ines-almeida) wrote :

Thanks for addressing my comments! Only an extra question.

I will mark my review as "abstain" because I will be off for a few days and don't want to block this MP from being merged if it gets approved.

review: Abstain
Revision history for this message
Simone Pelosi (pelpsi) wrote :

Thank you for your precious reviews, I firstly addressed some of your comments here, and I'm working to remove snapcraft.yaml logic to search for components as suggested by William. I will use the build files to retrieve them.

I also had a chat with Przemysław, and he clarified that snap_name cannot contain `+` nor `_`, the same as component_name. So it should be easier to extract the component name in that context `<snap_name>+<component_name>_<component_revision>.comp`.

Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/snappy/interfaces/snapbuildjob.py b/lib/lp/snappy/interfaces/snapbuildjob.py
2index 12e4b8b..90a8d37 100644
3--- a/lib/lp/snappy/interfaces/snapbuildjob.py
4+++ b/lib/lp/snappy/interfaces/snapbuildjob.py
5@@ -13,7 +13,7 @@ __all__ = [
6 from lazr.restful.fields import Reference
7 from zope.interface import Attribute, Interface
8 from zope.interface.interfaces import IObjectEvent
9-from zope.schema import Int, TextLine
10+from zope.schema import Dict, Int, TextLine
11
12 from lp import _
13 from lp.services.job.interfaces.job import IJob, IJobSource, IRunnableJob
14@@ -68,6 +68,17 @@ class ISnapStoreUploadJob(IRunnableJob):
15 readonly=True,
16 )
17
18+ components_ids = Dict(
19+ title=_(
20+ "The IDs returned by the store when uploading snap components."
21+ "The key is the component name and the value is the related id."
22+ ),
23+ key_type=TextLine(),
24+ value_type=TextLine(),
25+ required=False,
26+ readonly=True,
27+ )
28+
29 status_url = TextLine(
30 title=_("The URL on the store to get the status of this build"),
31 required=False,
32diff --git a/lib/lp/snappy/model/snapbuildjob.py b/lib/lp/snappy/model/snapbuildjob.py
33index 539fb1c..9a73f64 100644
34--- a/lib/lp/snappy/model/snapbuildjob.py
35+++ b/lib/lp/snappy/model/snapbuildjob.py
36@@ -244,6 +244,18 @@ class SnapStoreUploadJob(SnapBuildJobDerived):
37 self.snapbuild.store_upload_metadata["upload_id"] = upload_id
38
39 @property
40+ def components_ids(self):
41+ """See `ISnapStoreUploadJob`."""
42+ return self.store_metadata.get("components_ids")
43+
44+ @components_ids.setter
45+ def components_ids(self, components_ids):
46+ """See `ISnapStoreUploadJob`."""
47+ if self.snapbuild.store_upload_metadata is None:
48+ self.snapbuild.store_upload_metadata = {}
49+ self.snapbuild.store_upload_metadata["components_ids"] = components_ids
50+
51+ @property
52 def status_url(self):
53 """See `ISnapStoreUploadJob`."""
54 return self.store_metadata.get("status_url")
55@@ -333,6 +345,22 @@ class SnapStoreUploadJob(SnapBuildJobDerived):
56 pass
57 return timedelta(minutes=1)
58
59+ @staticmethod
60+ def _extract_component_name(filename):
61+ """Extract component name from built component filename
62+
63+ The built filename has the following pattern:
64+ <snap_name>+<component_name>_<component_revision>.comp.
65+ <snap_name> and <component_name> cannot contain `+` nor `_`
66+ by desing.
67+ """
68+ start = filename.find("+")
69+ end = filename.find("_")
70+ if start == -1 or end == -1:
71+ return filename
72+ else:
73+ return filename[start + 1 : end]
74+
75 def run(self):
76 """See `IRunnableJob`."""
77 client = getUtility(ISnapStoreClient)
78@@ -350,16 +378,43 @@ class SnapStoreUploadJob(SnapBuildJobDerived):
79 # Nothing to do.
80 self.error_message = None
81 return
82+
83+ # Get components if any
84+ components = []
85+ components_ids = {}
86+ for _, lfa, _ in self.snapbuild.getFiles():
87+ if lfa.filename.endswith(".comp"):
88+ comp_name = self._extract_component_name(lfa.filename)
89+ components_ids[comp_name] = None
90+ components.append(lfa)
91+
92+ if "components_ids" not in self.store_metadata:
93+ self.components_ids = components_ids
94 if "upload_id" not in self.store_metadata:
95 self.upload_id = client.uploadFile(snap_lfa)
96 # We made progress, so reset attempt_count.
97 self.attempt_count = 1
98+
99+ # Process components
100+ for component in components:
101+ # if the id is None, we need to upload the component
102+ # because it means that that component was never uploaded.
103+ # Note that the id is returned directly from SnapStore API.
104+ comp_name = self._extract_component_name(
105+ component.filename
106+ )
107+ if self.components_ids.get(comp_name) is None:
108+ self.components_ids[comp_name] = client.uploadFile(
109+ component
110+ )
111+ self.attempt_count = 1
112 if "status_url" not in self.store_metadata:
113 self.status_url = client.push(
114- self.snapbuild, self.upload_id
115+ self.snapbuild, self.upload_id, self.components_ids
116 )
117 # We made progress, so reset attempt_count.
118 self.attempt_count = 1
119+
120 if self.store_url is None:
121 # This is no longer strictly necessary as the store is
122 # handling releases via the release intent, but we
123diff --git a/lib/lp/snappy/model/snapstoreclient.py b/lib/lp/snappy/model/snapstoreclient.py
124index 536ba17..98d2753 100644
125--- a/lib/lp/snappy/model/snapstoreclient.py
126+++ b/lib/lp/snappy/model/snapstoreclient.py
127@@ -293,7 +293,7 @@ class SnapStoreClient:
128 lfa.close()
129
130 @classmethod
131- def _push(cls, snapbuild, upload_id):
132+ def _push(cls, snapbuild, upload_id, components=None):
133 """Create a new store upload based on the uploaded file."""
134 snap = snapbuild.snap
135 assert snap.can_upload_to_store
136@@ -305,6 +305,10 @@ class SnapStoreClient:
137 "series": snap.store_series.name,
138 "built_at": snapbuild.date_started.isoformat(),
139 }
140+
141+ if components:
142+ data["components"] = components
143+
144 # The security proxy is useless and breaks JSON serialisation.
145 channels = removeSecurityProxy(snap.store_channels)
146 if channels:
147@@ -343,10 +347,10 @@ class SnapStoreClient:
148 raise cls._makeSnapStoreError(UploadFailedResponse, e)
149
150 @classmethod
151- def push(cls, snapbuild, upload_id):
152+ def push(cls, snapbuild, upload_id, components=None):
153 """See `ISnapStoreClient`."""
154 return cls.refreshIfNecessary(
155- snapbuild.snap, cls._push, snapbuild, upload_id
156+ snapbuild.snap, cls._push, snapbuild, upload_id, components
157 )
158
159 @classmethod
160diff --git a/lib/lp/snappy/tests/test_snapbuildjob.py b/lib/lp/snappy/tests/test_snapbuildjob.py
161index 7e69f3c..d5239ae 100644
162--- a/lib/lp/snappy/tests/test_snapbuildjob.py
163+++ b/lib/lp/snappy/tests/test_snapbuildjob.py
164@@ -18,6 +18,7 @@ from zope.interface import implementer
165 from zope.security.proxy import removeSecurityProxy
166
167 from lp.buildmaster.enums import BuildStatus
168+from lp.code.tests.helpers import BranchHostingFixture
169 from lp.services.config import config
170 from lp.services.database.interfaces import IStore
171 from lp.services.job.interfaces.job import JobStatus
172@@ -61,10 +62,30 @@ def run_isolated_jobs(jobs):
173 transaction.abort()
174
175
176+class FakeMethodWithMaxCalls(FakeMethod):
177+ """Method that throws error after a given number of calls"""
178+
179+ def __init__(self, max_calls=-1):
180+ self.max_calls = max_calls
181+ super().__init__(result=1, failure=None)
182+
183+ def __call__(self, *args, **kwargs):
184+ if self.call_count == self.max_calls:
185+ self.failure = UploadFailedResponse("Proxy error", can_retry=True)
186+ self.result = None
187+ else:
188+ self.result = 1
189+ self.failure = None
190+ return super().__call__(*args, **kwargs)
191+
192+
193 @implementer(ISnapStoreClient)
194 class FakeSnapStoreClient:
195- def __init__(self):
196- self.uploadFile = FakeMethod()
197+ def __init__(self, max_calls=-1):
198+ if max_calls >= 0:
199+ self.uploadFile = FakeMethodWithMaxCalls(max_calls)
200+ else:
201+ self.uploadFile = FakeMethod()
202 self.push = FakeMethod()
203 self.checkStatus = FakeMethod()
204
205@@ -144,6 +165,49 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
206 )
207 return snapbuild
208
209+ def makeSnapBuildWithComponents(self, num_components):
210+ # Make a build with a builder, components, and a webhook.
211+ snapcraft_yaml = """
212+ name : test-snap,
213+ components:
214+ """
215+ branch = self.factory.makeBranch()
216+ snap = self.factory.makeSnap(store_name="test-snap", branch=branch)
217+ snapbuild = self.factory.makeSnapBuild(
218+ snap=snap, builder=self.factory.makeBuilder()
219+ )
220+ snapbuild.updateStatus(BuildStatus.FULLYBUILT)
221+ snap_lfa = self.factory.makeLibraryFileAlias(
222+ filename="test-snap_0_all.snap", content=b"dummy snap content"
223+ )
224+ for i in range(num_components):
225+ self.factory.makeSnapFile(
226+ snapbuild=snapbuild, libraryfile=snap_lfa
227+ )
228+ component_lfa = self.factory.makeLibraryFileAlias(
229+ filename="test-snap+somecomponent%s_0.comp" % i,
230+ content=b"component",
231+ )
232+ self.factory.makeSnapFile(
233+ snapbuild=snapbuild, libraryfile=component_lfa
234+ )
235+ snapcraft_yaml += (
236+ """
237+ somecomponent%s:
238+ description: test
239+ """
240+ % i
241+ )
242+ self.useFixture(
243+ BranchHostingFixture(
244+ blob=bytes(snapcraft_yaml, "utf-8"),
245+ )
246+ )
247+ self.factory.makeWebhook(
248+ target=snapbuild.snap, event_types=["snap:build:0.1"]
249+ )
250+ return snapbuild
251+
252 def assertWebhookDeliveries(
253 self, snapbuild, expected_store_upload_statuses, logger
254 ):
255@@ -207,7 +271,101 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
256 self.assertThat(
257 client.uploadFile.calls, FileUploaded("test-snap.snap")
258 )
259- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
260+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
261+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
262+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
263+ self.assertEqual(self.store_url, job.store_url)
264+ self.assertEqual(1, job.store_revision)
265+ self.assertEqual(1, snapbuild.store_upload_revision)
266+ self.assertIsNone(job.error_message)
267+ self.assertEqual([], pop_notifications())
268+ self.assertWebhookDeliveries(
269+ snapbuild, ["Pending", "Uploaded"], logger
270+ )
271+
272+ def test_run_with_component(self):
273+ # The job uploads the build to the storage with its component
274+ # and then pushes it to the store and records the store URL
275+ # and revision.
276+ logger = self.useFixture(FakeLogger())
277+ snapbuild = self.makeSnapBuildWithComponents(1)
278+ self.assertContentEqual([], snapbuild.store_upload_jobs)
279+ job = SnapStoreUploadJob.create(snapbuild)
280+ client = FakeSnapStoreClient()
281+ client.uploadFile.result = 1
282+ client.push.result = self.status_url
283+ client.checkStatus.result = (self.store_url, 1)
284+ self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
285+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
286+ run_isolated_jobs([job])
287+
288+ # Check if uploadFile is called for snap and its component.
289+ self.assertThat(
290+ [client.uploadFile.calls[0]], FileUploaded("test-snap_0_all.snap")
291+ )
292+ self.assertThat(
293+ [client.uploadFile.calls[1]],
294+ FileUploaded("test-snap+somecomponent0_0.comp"),
295+ )
296+ self.assertEqual(
297+ [((snapbuild, 1, {"somecomponent0": 1}), {})],
298+ client.push.calls,
299+ )
300+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
301+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
302+ self.assertEqual(self.store_url, job.store_url)
303+ self.assertEqual(1, job.store_revision)
304+ self.assertEqual(1, snapbuild.store_upload_revision)
305+ self.assertIsNone(job.error_message)
306+ self.assertEqual([], pop_notifications())
307+ self.assertWebhookDeliveries(
308+ snapbuild, ["Pending", "Uploaded"], logger
309+ )
310+
311+ def test_run_with_multiple_components(self):
312+ # The job uploads the build to the storage with its components
313+ # and then pushes it to the store and records the store URL
314+ # and revision.
315+ logger = self.useFixture(FakeLogger())
316+ snapbuild = self.makeSnapBuildWithComponents(2)
317+ self.assertContentEqual([], snapbuild.store_upload_jobs)
318+ job = SnapStoreUploadJob.create(snapbuild)
319+ client = FakeSnapStoreClient()
320+ client.uploadFile.result = 1
321+ client.push.result = self.status_url
322+ client.checkStatus.result = (self.store_url, 1)
323+ self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
324+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
325+ run_isolated_jobs([job])
326+
327+ # Check if uploadFile is called for snap and its components.
328+ self.assertThat(
329+ [client.uploadFile.calls[0]], FileUploaded("test-snap_0_all.snap")
330+ )
331+ self.assertThat(
332+ [client.uploadFile.calls[1]],
333+ FileUploaded("test-snap+somecomponent0_0.comp"),
334+ )
335+ self.assertThat(
336+ [client.uploadFile.calls[2]],
337+ FileUploaded("test-snap+somecomponent1_0.comp"),
338+ )
339+ self.assertEqual(
340+ [
341+ (
342+ (
343+ snapbuild,
344+ 1,
345+ {
346+ "somecomponent0": 1,
347+ "somecomponent1": 1,
348+ },
349+ ),
350+ {},
351+ )
352+ ],
353+ client.push.calls,
354+ )
355 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
356 self.assertContentEqual([job], snapbuild.store_upload_jobs)
357 self.assertEqual(self.store_url, job.store_url)
358@@ -268,7 +426,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
359 self.assertThat(
360 client.uploadFile.calls, FileUploaded("test-snap.snap")
361 )
362- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
363+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
364 self.assertEqual([], client.checkStatus.calls)
365 self.assertContentEqual([job], snapbuild.store_upload_jobs)
366 self.assertIsNone(job.store_url)
367@@ -313,6 +471,106 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
368 snapbuild, ["Pending", "Failed to upload"], logger
369 )
370
371+ def test_run_502_retries_with_components(self):
372+ # A run that gets a 502 error from the store schedules itself to be
373+ # retried.
374+ logger = self.useFixture(FakeLogger())
375+ snapbuild = self.makeSnapBuildWithComponents(2)
376+ self.assertContentEqual([], snapbuild.store_upload_jobs)
377+ job = SnapStoreUploadJob.create(snapbuild)
378+
379+ # The error raises on the third uploadFile call.
380+ client = FakeSnapStoreClient(2)
381+ client.uploadFile.result = 1
382+ self.useFixture(ZopeUtilityFixture(client, ISnapStoreClient))
383+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
384+ run_isolated_jobs([job])
385+ self.assertThat(
386+ [client.uploadFile.calls[0]], FileUploaded("test-snap_0_all.snap")
387+ )
388+ self.assertThat(
389+ [client.uploadFile.calls[1]],
390+ FileUploaded("test-snap+somecomponent0_0.comp"),
391+ )
392+ self.assertThat(
393+ [client.uploadFile.calls[2]],
394+ FileUploaded("test-snap+somecomponent1_0.comp"),
395+ )
396+ self.assertEqual([], client.push.calls)
397+ self.assertEqual([], client.checkStatus.calls)
398+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
399+
400+ # Component upload is incomplete.
401+ self.assertEqual(
402+ {
403+ "somecomponent0": 1,
404+ "somecomponent1": None,
405+ },
406+ job.components_ids,
407+ )
408+ self.assertIsNone(job.store_url)
409+ self.assertIsNone(job.store_revision)
410+ self.assertIsNone(snapbuild.store_upload_revision)
411+ self.assertIsNone(job.error_message)
412+ self.assertEqual([], pop_notifications())
413+ self.assertEqual(JobStatus.WAITING, job.job.status)
414+ self.assertWebhookDeliveries(snapbuild, ["Pending"], logger)
415+
416+ # Try again. The upload part of the job is retried, and this time
417+ # it succeeds.
418+ job.scheduled_start = None
419+ client.uploadFile.max_calls = -1
420+ client.uploadFile.failure = None
421+ client.uploadFile.result = 1
422+ client.push.result = self.status_url
423+ client.checkStatus.result = (self.store_url, 1)
424+ with dbuser(config.ISnapStoreUploadJobSource.dbuser):
425+ run_isolated_jobs([job])
426+
427+ # The last call should contains just the component that failed.
428+ self.assertEqual(len(client.uploadFile.calls), 4)
429+ self.assertThat(
430+ [client.uploadFile.calls[3]],
431+ FileUploaded("test-snap+somecomponent1_0.comp"),
432+ )
433+
434+ self.assertEqual(
435+ {
436+ "somecomponent0": 1,
437+ "somecomponent1": 1,
438+ },
439+ job.components_ids,
440+ )
441+
442+ # After that the snap is uploaded to the store.
443+ self.assertEqual(
444+ [
445+ (
446+ (
447+ snapbuild,
448+ 1,
449+ {
450+ "somecomponent0": 1,
451+ "somecomponent1": 1,
452+ },
453+ ),
454+ {},
455+ )
456+ ],
457+ client.push.calls,
458+ )
459+ self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
460+ self.assertContentEqual([job], snapbuild.store_upload_jobs)
461+ self.assertEqual(self.store_url, job.store_url)
462+ self.assertEqual(1, job.store_revision)
463+ self.assertEqual(1, snapbuild.store_upload_revision)
464+ self.assertIsNone(job.error_message)
465+ self.assertEqual([], pop_notifications())
466+ self.assertEqual(JobStatus.COMPLETED, job.job.status)
467+ self.assertWebhookDeliveries(
468+ snapbuild, ["Pending", "Uploaded"], logger
469+ )
470+
471 def test_run_502_retries(self):
472 # A run that gets a 502 error from the store schedules itself to be
473 # retried.
474@@ -353,7 +611,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
475 self.assertThat(
476 client.uploadFile.calls, FileUploaded("test-snap.snap")
477 )
478- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
479+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
480 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
481 self.assertContentEqual([job], snapbuild.store_upload_jobs)
482 self.assertEqual(self.store_url, job.store_url)
483@@ -388,7 +646,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
484 self.assertThat(
485 client.uploadFile.calls, FileUploaded("test-snap.snap")
486 )
487- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
488+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
489 self.assertEqual([], client.checkStatus.calls)
490 self.assertContentEqual([job], snapbuild.store_upload_jobs)
491 self.assertIsNone(job.store_url)
492@@ -524,7 +782,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
493 self.assertThat(
494 client.uploadFile.calls, FileUploaded("test-snap.snap")
495 )
496- self.assertEqual([((snapbuild, 2), {})], client.push.calls)
497+ self.assertEqual([((snapbuild, 2, {}), {})], client.push.calls)
498 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
499 self.assertContentEqual([job], snapbuild.store_upload_jobs)
500 self.assertIsNone(job.store_url)
501@@ -586,7 +844,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
502 self.assertThat(
503 client.uploadFile.calls, FileUploaded("test-snap.snap")
504 )
505- self.assertEqual([((snapbuild, 2), {})], client.push.calls)
506+ self.assertEqual([((snapbuild, 2, {}), {})], client.push.calls)
507 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
508 self.assertContentEqual([job], snapbuild.store_upload_jobs)
509 self.assertIsNone(job.store_url)
510@@ -655,7 +913,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
511 self.assertThat(
512 client.uploadFile.calls, FileUploaded("test-snap.snap")
513 )
514- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
515+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
516 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
517 self.assertContentEqual([job], snapbuild.store_upload_jobs)
518 self.assertIsNone(job.store_url)
519@@ -684,7 +942,7 @@ class TestSnapStoreUploadJob(TestCaseWithFactory):
520 self.assertThat(
521 client.uploadFile.calls, FileUploaded("test-snap.snap")
522 )
523- self.assertEqual([((snapbuild, 1), {})], client.push.calls)
524+ self.assertEqual([((snapbuild, 1, {}), {})], client.push.calls)
525 self.assertEqual([((self.status_url,), {})], client.checkStatus.calls)
526 self.assertContentEqual([job], snapbuild.store_upload_jobs)
527 self.assertEqual(self.store_url, job.store_url)

Subscribers

People subscribed via source and target branches

to status/vote changes: