Merge lp:~adeuring/charmworld/1180118-check-if-charm-is-deleted into lp:~juju-jitsu/charmworld/trunk

Proposed by Abel Deuring
Status: Merged
Approved by: Abel Deuring
Approved revision: 253
Merged at revision: 256
Proposed branch: lp:~adeuring/charmworld/1180118-check-if-charm-is-deleted
Merge into: lp:~juju-jitsu/charmworld/trunk
Diff against target: 475 lines (+220/-37)
8 files modified
charmworld/jobs/ingest.py (+6/-0)
charmworld/jobs/lp.py (+43/-17)
charmworld/jobs/tests/test_bzr.py (+33/-0)
charmworld/jobs/tests/test_ingest.py (+1/-0)
charmworld/jobs/tests/test_lp.py (+114/-19)
charmworld/jobs/tests/test_proof.py (+13/-0)
charmworld/models.py (+7/-0)
charmworld/testing/factory.py (+3/-1)
To merge this branch: bzr merge lp:~adeuring/charmworld/1180118-check-if-charm-is-deleted
Reviewer Review Type Date Requested Status
Abel Deuring (community) Approve
Aaron Bentley (community) Approve
Review via email: mp+168050@code.launchpad.net

Commit message

jobs.lp: queue also charms that do no longer have a Launchpad branch.

Description of the change

This branch fixes the "deleted branches" part of bug 1180118:
Charmworld does not know about deleted/merged charms

It adds a new attribute "branch_deleted" to the charm data; those parts
of the ingest job are skipped that require access to a charm's files.

The queue/lp job now also uses the set of all charms stored in
the Mongo DB as a second source of charms that will be checked
by the ingest job.

This second source implemented in the new function jobs.lp.db_charms()

The sequences db_charms() and available_charms() are merged by a
new function job.utils.merge_sequences(a, b). This function compares two
elements from each source; if they are considered to be equal,
the value from sequence a is returned, otherwise the "smaller" value
is returned.

The sequences returned by db_charms() and available_charms() are ordered
by branch_spec, hence cmp(x['branch_spec'], y['branch_spec']) is used
as the comparison function in merge_sequences.

Since the data from db_charms() is only used when available_charms()
does not have a corresponding element, db_charms() can already set
the values charm['promulgated'], charm['branch_deleted'] and
charm['distro_series'].

Implementation note for merge_sequences(): I played at first with
try: ... except StopIteration: ... in this function but I found it
hard to find a half way readable implementation that works correctly
in the "elif compared == 0" part when one or both of the "source
sequences" run out of elements. Appending a "marker element" to these
sequences made the implementaion mch easier to read.

To post a comment you must log in.
Revision history for this message
Aaron Bentley (abentley) wrote :

This code needs further work. The merge_sequences work is supposed to prevent memory exhaustion, but the possibility of memory exhaustion is vanishingly remote. The size of the charm dicts is measured in kilobytes, but the memory size of current machines is measured in gigabytes. We would need hundreds of millions of charms to run the risk of memory exhaustion. And even if this code prevented memory exhaustion from the db lookups, it doesn't address the memory impact of the LP lookup in get_branch_tips. So even with merge_sequences, the memory use would be half, which is the same order of magnitude.

This is premature optimization. If we actually start to encounter memory issues, we should solve it then. I expect the branch lookups are one of the last places that will give us issues. And we will likely have changed that code before we experience such issues, anyhow.

We don't have a LOC limit in this project, and I don't think we should, but I don't think the 90+ lines required by merge_sequences are worth the maintenance burden.

Here is a patch that provides the obvious implementation: http://pastebin.ubuntu.com/5742373/

Please apply the patch or take a similar approach.

Note that the patch uses basic data structures like dict and list. That kind of code is always easier to maintain than code that uses project-specific functions.

There are also bugs in the handling of the all_charms limit and default parameters. The import_filter is ignored entirely, and import_filter=default is supplied to available_charms.

The limit is applied only to the LP data, not the db data. This means that too many results will be returned (assuming the number of charms in the db exceeds limit), and that any results excluded from the available_charms output because of the limit that are present in the DB will incorrectly be assigned branch_deleted = True.

Simply applying a limit to both sets is not going to work, because discrepancies between the db and LP's branch listings will cause a branch to be excluded from the LP data based on the limit, while include in the DB data based on the limit. For example:

LP: ['a', 'b', 'c']
DB: ['a', 'c']

With a limit of 2, the LP set will be ['a', 'b'] and DB set will be ['a', 'c'], falsely implying that 'c' was deleted from the DB.

I think the simplest option is to apply the limit after merging LP and DB data.

It's arguable that ProofIngestJob should not abort if the branch is deleted, since we would want the proof data to be updated as our proofer is updated. Of course, it would need to abort if the branch_dir does not exist. (Yes, once the number of worker nodes increases, relying on the branch cache means that you may randomly get different results depending on the worker.)

review: Needs Fixing
Revision history for this message
Aaron Bentley (abentley) wrote :

Also, please put docstrings on all public functions.

Revision history for this message
Abel Deuring (adeuring) wrote :

The two sequences available_charms() and db_charms() are now merged via dict.update(); CHARM_IMPORT_FILTER and limit are now used in all_charms().

But I am not sure if we should try to proof charm without LP branches: As you noted, this can lead to random behaviour when we have more than one worker.

Alternatively, we could pull the charm data from the main store when no LP data is available. But I am not sure if we care enough about this sort of "half-dead"/abandoned charms. (We should discuss though if it makes sense to pull the charm data from the store for _all_ charms and use that instead of the LP branches as our main source. This might avoid a few other possible inconsistencies.)

Revision history for this message
Aaron Bentley (abentley) wrote :

This looks good, except that I believe _merge_iter is unused and untested code. Please consider removing it.

I am find with disabling proofing for now-- I just thought it was worth mentioning.

review: Approve
Revision history for this message
Charmworld Lander (charmworld-lander) wrote :

There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.

Revision history for this message
Abel Deuring (adeuring) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'charmworld/jobs/ingest.py'
2--- charmworld/jobs/ingest.py 2013-06-05 18:33:46 +0000
3+++ charmworld/jobs/ingest.py 2013-06-10 14:27:33 +0000
4@@ -156,6 +156,8 @@
5
6 def run(self, charm_data, retry=True):
7 """Fetch a branch from bzr, and augment charm data."""
8+ if charm_data['branch_deleted']:
9+ return
10 branch_dir = os.path.abspath(
11 str(os.path.join(self.root_dir,
12 charm_data["series"],
13@@ -287,6 +289,8 @@
14 return d
15
16 def run(self, charm_data):
17+ if charm_data['branch_deleted']:
18+ return
19 branch_dir = charm_data["branch_dir"]
20 charm_data.update(self.get_changes(branch_dir))
21
22@@ -482,6 +486,8 @@
23 if not self.proofer:
24 self.log.exception("proof aborted.")
25 raise Exception("No proofer")
26+ if charm['branch_deleted']:
27+ return
28 proof = {}
29 lint, exit_code = self.proofer(charm['branch_dir'])
30 for line in lint:
31
32=== modified file 'charmworld/jobs/lp.py'
33--- charmworld/jobs/lp.py 2013-06-05 15:12:51 +0000
34+++ charmworld/jobs/lp.py 2013-06-10 14:27:33 +0000
35@@ -4,6 +4,7 @@
36
37 import argparse
38 import logging
39+import pymongo
40
41 from charmworld.lp import get_branch_tips
42 from charmworld.models import getconnection
43@@ -14,24 +15,21 @@
44 )
45 from config import CHARM_IMPORT_FILTER
46 from config import CHARM_QUEUE
47+from utils import get_queue
48 from utils import lock
49 from utils import LockHeld
50-from utils import get_queue
51 from utils import parse_branch
52
53
54 default = object()
55
56
57-def available_charms(charm_data=None, limit=None, import_filter=default):
58- if import_filter is default:
59- import_filter = CHARM_IMPORT_FILTER
60+def available_charms(charm_data=None):
61+ # Return data needed for the ingest job for charm branches on Launchpad.
62 log = logging.getLogger("charm.launchpad")
63 if not charm_data:
64 charm_data = get_branch_tips()
65
66- count = 0
67-
68 for repo, commit, series in charm_data:
69 _, branch_name = repo.rsplit("/", 1)
70
71@@ -41,32 +39,60 @@
72 log.warning("Unable to parse repo %s", repo)
73 continue
74
75- if import_filter and not data['branch_spec'].startswith(
76- import_filter):
77- continue
78-
79 if data["bname"] not in ("trunk", "trunk-1"):
80 log.debug("Skipped branch %s", repo)
81 continue
82
83- # XXX Abel Deuring 2013-05-24 bug=1180118
84- # This breaks if a promulgated branch is unpromulgated and
85- # at the same time deleted from Launchpad.
86 data['promulgated'] = data['series'] in data['distro_series']
87+ data['branch_deleted'] = False
88
89- log.info("Queueing %s", data['branch_spec'])
90 yield data
91
92+
93+def db_charms(db):
94+ # Return data needed for the ingest job for charms stored in
95+ # charmworld's database.
96+ for charm in db.charms.find(
97+ fields=('owner', 'series', 'name', 'bname', 'branch_spec'),
98+ sort=[('branch_spec', pymongo.ASCENDING)]):
99+ # Override the promulgation related settings: Data from this
100+ # iterator is used by all_charms() only when the charm has no
101+ # longer a related Launchpad branch. A charm is promulgated if
102+ # its LP branch is linked to a sourcepackage, hence a charm
103+ # without a branch cannot be promulgated.
104+ charm['promulgated'] = False
105+ charm['distro_series'] = []
106+ charm['branch_deleted'] = True
107+ yield charm
108+
109+
110+def all_charms(db, charm_data=None, limit=None, import_filter=default):
111+ # Return a sequence of charms that have a Launchpad branch or that are
112+ # already known in charmworld's database.
113+ log = logging.getLogger("charm.launchpad")
114+ if import_filter is default:
115+ import_filter = CHARM_IMPORT_FILTER
116+ all = dict((charm['branch_spec'], charm) for charm in db_charms(db))
117+ available = available_charms(charm_data)
118+ all.update((charm['branch_spec'], charm) for charm in available)
119+ all = sorted(all.values(), key=lambda charm: charm['branch_spec'])
120+
121+ count = 0
122+ for charm in all:
123+ if import_filter and not charm['branch_spec'].startswith(
124+ import_filter):
125+ continue
126+ log.info("Queueing %s", charm['branch_spec'])
127+ yield charm
128 count += 1
129-
130 if limit and count >= limit:
131 log.info("Import limit reached (%d)... stopping", limit)
132 break
133
134
135-def queue_charms(out_queue, limit=None, import_filter=default):
136+def queue_charms(db, out_queue, limit=None, import_filter=default):
137 log = logging.getLogger("charm.launchpad")
138- for charm in available_charms(limit=limit, import_filter=import_filter):
139+ for charm in all_charms(db, limit=limit, import_filter=import_filter):
140 added = out_queue.put(charm)
141 if added and 0:
142 log.info("Queued %s", charm)
143
144=== modified file 'charmworld/jobs/tests/test_bzr.py'
145--- charmworld/jobs/tests/test_bzr.py 2013-06-05 18:30:47 +0000
146+++ charmworld/jobs/tests/test_bzr.py 2013-06-10 14:27:33 +0000
147@@ -104,6 +104,30 @@
148 self.assertIn('files', charm_data)
149 self.assertNotIn('icon', charm_data)
150
151+ def test_deleted_branch(self):
152+ # If the Launchpad branch of a charm is deleted, BzrJob does
153+ # not do anything.
154+ job = BzrIngestJob()
155+ job.setup(root_dir=self.test_dir, db=self.db)
156+ ignore, charm = factory.makeCharm(
157+ self.db, branch_root=self.test_dir, branch_deleted=True)
158+ charm_path = os.path.abspath(os.path.join(
159+ self.test_dir,
160+ charm['series'],
161+ charm['owner'],
162+ charm['name'],
163+ charm['bname']))
164+ with patch.object(job, 'checkout_charm') as checkout_mock:
165+ with patch.object(job, 'charm_is_current') as is_current_mock:
166+ with patch.object(job, 'add_files') as add_files_mock:
167+ with patch.object(job, 'update_charm') as update_mock:
168+ job.run(charm)
169+ self.assertFalse(os.path.exists(charm_path))
170+ self.assertFalse(checkout_mock.called)
171+ self.assertFalse(is_current_mock.called)
172+ self.assertFalse(add_files_mock.called)
173+ self.assertFalse(update_mock.called)
174+
175
176 @contextmanager
177 def bzr_isolation():
178@@ -247,3 +271,12 @@
179 self.assertEqual([], charm_data['changes'])
180 self.assertIs(None, charm_data['first_change'])
181 self.assertIs(None, charm_data['last_change'])
182+
183+ def test_branch_deleted(self):
184+ # ChangelogIngestJob does not do anything if the Launchpad branch
185+ # of a charm is deleted.
186+ job = ChangelogIngestJob()
187+ charm = factory.get_charm_json(branch_deleted=True)
188+ with patch.object(job, 'get_changes') as mock:
189+ job.run(charm)
190+ self.assertFalse(mock.called)
191
192=== modified file 'charmworld/jobs/tests/test_ingest.py'
193--- charmworld/jobs/tests/test_ingest.py 2013-05-29 20:43:01 +0000
194+++ charmworld/jobs/tests/test_ingest.py 2013-06-10 14:27:33 +0000
195@@ -108,6 +108,7 @@
196 'name': charm_data['name'],
197 'bname': charm_data['bname'],
198 'branch_spec': charm_data['branch_spec'],
199+ 'branch_deleted': charm_data['branch_deleted'],
200 'promulgated': charm_data['promulgated'],
201 }
202
203
204=== modified file 'charmworld/jobs/tests/test_lp.py'
205--- charmworld/jobs/tests/test_lp.py 2013-06-05 18:33:46 +0000
206+++ charmworld/jobs/tests/test_lp.py 2013-06-10 14:27:33 +0000
207@@ -7,10 +7,13 @@
208 from mock import patch
209
210 from charmworld.testing import (
211+ factory,
212 JobTestBase,
213 )
214
215 from charmworld.jobs.lp import (
216+ all_charms,
217+ db_charms,
218 available_charms,
219 queue_charms,
220 )
221@@ -36,6 +39,7 @@
222
223 self.assertEqual([{
224 'branch_spec': u'~charmers/charms/precise/someproject/trunk',
225+ 'branch_deleted': False,
226 'owner': u'charmers',
227 'series': u'precise',
228 'name': u'someproject',
229@@ -57,6 +61,7 @@
230
231 self.assertEqual([{
232 'branch_spec': u'~charmers/charms/precise/someproject/trunk',
233+ 'branch_deleted': False,
234 'owner': u'charmers',
235 'series': u'precise',
236 'name': u'someproject',
237@@ -78,30 +83,37 @@
238 self.assertIn(err_msg, log_messages)
239 self.assertEqual([], charms)
240
241- def test_available_charms_filter(self):
242+ def test_all_charms_filter(self):
243 # If the branch doesn't start with the defined filter (i.e.
244 # ~charmers/charms/precise) it is not returned
245 charm_data = [[u'~not-charmers/charms/precise/someproject/trunk',
246 u'ja@appflower.com-20120329093714-s2m9e28dwotmijqc',
247 []]]
248+ factory.makeCharm(self.db, owner='not-charmers')
249 with patch('charmworld.jobs.lp.CHARM_IMPORT_FILTER',
250 '~charmers/charms/precise'):
251- charms = [charm for charm in available_charms(charm_data)]
252+ charms = [charm for charm in all_charms(self.db, charm_data)]
253 self.assertEqual([], charms)
254
255- def test_available_charms_explicit_filter(self):
256+ def test_all_charms_explicit_filter(self):
257 # If the branch doesn't start with the defined filter (i.e.
258 # ~charmers/charms/precise) it is not returned
259- charm_data = [[u'~not-charmers/charms/precise/someproject/trunk',
260- u'ja@appflower.com-20120329093714-s2m9e28dwotmijqc',
261- []],
262- [u'~charmers/charms/precise/someproject/trunk',
263- u'ja@appflower.com-20120329093714-s2m9e28dwotmijqc',
264- []],
265- ]
266- charms = available_charms(
267- charm_data, import_filter='~charmers/charms/precise')
268- self.assertEqual(['~charmers/charms/precise/someproject/trunk'],
269+ lp_data = [
270+ [u'~not-charmers/charms/precise/someproject/trunk',
271+ u'ja@appflower.com-20120329093714-s2m9e28dwotmijqc',
272+ []],
273+ [u'~charmers/charms/precise/someproject/trunk',
274+ u'ja@appflower.com-20120329093714-s2m9e28dwotmijqc',
275+ []],
276+ ]
277+ factory.makeCharm(
278+ self.db, owner='charmers', name='only-in-db')
279+ factory.makeCharm(
280+ self.db, owner='not-charmers', name='only-in-db')
281+ charms = all_charms(
282+ self.db, lp_data, import_filter='~charmers/charms/precise')
283+ self.assertEqual(['~charmers/charms/precise/only-in-db/trunk',
284+ '~charmers/charms/precise/someproject/trunk'],
285 [charm['branch_spec'] for charm in charms])
286
287 def test_available_charms_not_trunk(self):
288@@ -117,22 +129,105 @@
289 self.assertIn(err_msg, log_messages)
290 self.assertEqual([], charms)
291
292- def test_available_charm_limit(self):
293+ def test_all_charms_limit(self):
294 handler = self.get_handler("charm.launchpad")
295- charm_data = [[u'~charmers/charms/precise/someproject/trunk',
296- u'ja@appflower.com-20120329093714-s2m9e28dwotmijqc',
297- []]] * 200
298- charms = [charm for charm in available_charms(charm_data, limit=110)]
299+ charm_data = [
300+ [u'~charmers/charms/precise/someproject-%s/trunk' % count,
301+ u'ja@appflower.com-20120329093714-s2m9e28dwotmijqc', []]
302+ for count in xrange(100)]
303+ for count in range(20):
304+ factory.makeCharm(self.db)
305+ charms = [charm for charm in all_charms(
306+ self.db, charm_data, limit=110)]
307 log_messages = [record.getMessage() for record in handler.buffer]
308 err_msg = ("Import limit reached (110)... stopping")
309 self.assertIn(err_msg, log_messages)
310 self.assertEqual(110, len(charms))
311
312+ def test_db_charms(self):
313+ charm_1 = factory.makeCharm(
314+ self.db, owner='o2', series='s1', name='c1')[1]
315+ charm_2 = factory.makeCharm(
316+ self.db, owner='o1', series='s2', name='c1')[1]
317+ charm_3 = factory.makeCharm(
318+ self.db, owner='o1', series='s1', name='c2')[1]
319+ charm_4 = factory.makeCharm(
320+ self.db, owner='o1', series='s1', name='c1')[1]
321+ result = [c['branch_spec'] for c in db_charms(self.db)]
322+ expected = [
323+ c['branch_spec'] for c in (charm_4, charm_3, charm_2, charm_1)]
324+ self.assertEqual(expected, result)
325+
326+ def makeLPData(self, owner, series, name, promulgated=False):
327+ if promulgated:
328+ linked_series = [series]
329+ else:
330+ linked_series = []
331+ return ['~%s/charms/%s/%s/trunk' % (owner, series, name),
332+ '%s@example.com-%s-%s' % (owner, series, name), linked_series]
333+
334+ def makeCharmAndLPData(self, owner, series, name, promulgated=False):
335+ charm = factory.makeCharm(
336+ self.db, owner=owner, series=series, name=name,
337+ promulgated=promulgated)[1]
338+ return charm, self.makeLPData(owner, series, name, promulgated)
339+
340+ def test_all_charms(self):
341+ lp_data = []
342+ charm_1, lp_1 = self.makeCharmAndLPData(
343+ 'owner', 'series', 'c1', promulgated=True)
344+ lp_data.append(lp_1)
345+ charm_2, lp_2 = self.makeCharmAndLPData(
346+ 'owner', 'series', 'c2', promulgated=True)
347+ lp_3 = self.makeLPData('owner', 'series', 'c3')
348+ lp_data.append(lp_3)
349+ result = list(all_charms(self.db, lp_data))
350+
351+ # Note that data for the promulgated charm_2 does not appear
352+ # in lp_data; db_charms() changed its promulgated status to
353+ # false.
354+ expected = [
355+ {
356+ 'branch_spec': '~owner/charms/series/c1/trunk',
357+ 'branch_deleted': False,
358+ 'bname': 'trunk',
359+ 'commit': 'owner@example.com-series-c1',
360+ 'distro_series': ['series'],
361+ 'name': 'c1',
362+ 'owner': 'owner',
363+ 'promulgated': True,
364+ 'series': 'series',
365+ },
366+ {
367+ 'branch_spec': '~owner/charms/series/c2/trunk',
368+ 'branch_deleted': True,
369+ 'bname': 'trunk',
370+ 'distro_series': [],
371+ 'name': 'c2',
372+ 'owner': 'owner',
373+ 'series': 'series',
374+ 'promulgated': False,
375+ '_id': '~owner/charms/series/c2/trunk',
376+ },
377+ {
378+ 'branch_spec': '~owner/charms/series/c3/trunk',
379+ 'branch_deleted': False,
380+ 'bname': 'trunk',
381+ 'commit': 'owner@example.com-series-c3',
382+ 'distro_series': [],
383+ 'name': 'c3',
384+ 'owner': 'owner',
385+ 'promulgated': False,
386+ 'series': 'series',
387+ },
388+ ]
389+ self.assertEqual(expected, result)
390+
391 @patch('charmworld.jobs.lp.available_charms', available_charms_mock)
392 def test_queue_charms(self):
393 out_queue = get_queue('test_queue')
394 self.addCleanup(out_queue.clear)
395- queue_charms(out_queue=out_queue)
396+ queue_charms(self.db, out_queue=out_queue)
397 item = out_queue.next()
398 self.assertEqual(
399 {'branch_dir': 'foo', 'branch_spec': 'bar'}, item.payload)
400
401=== modified file 'charmworld/jobs/tests/test_proof.py'
402--- charmworld/jobs/tests/test_proof.py 2013-04-23 15:27:26 +0000
403+++ charmworld/jobs/tests/test_proof.py 2013-06-10 14:27:33 +0000
404@@ -1,7 +1,10 @@
405 # Copyright 2012, 2013 Canonical Ltd. This software is licensed under the
406 # GNU Affero General Public License version 3 (see the file LICENSE).
407
408+from mock import patch
409+
410 from charmworld.jobs.ingest import ProofIngestJob
411+from charmworld.testing import factory
412 from charmworld.testing import JobTestBase
413
414
415@@ -19,3 +22,13 @@
416 "could not find charm proof path.")
417 self.assertIn(err, log_messages)
418 self.assertIn('CHARM_PROOF_PATH: %s' % nonexistant_path, log_messages)
419+
420+ def test_no_proof_for_deleted_branch(self):
421+ # If the branch of a charm is deleted from Launchpad, the proof
422+ # job does nothing.
423+ job = ProofIngestJob()
424+ job.setup()
425+ charm = factory.get_charm_json(branch_deleted=True)
426+ with patch.object(job, 'proofer') as mock:
427+ job.run(charm)
428+ self.assertFalse(mock.called)
429
430=== modified file 'charmworld/models.py'
431--- charmworld/models.py 2013-06-05 22:00:41 +0000
432+++ charmworld/models.py 2013-06-10 14:27:33 +0000
433@@ -191,6 +191,7 @@
434 # Provided by Lp charms queue.
435 'branch_spec': '',
436 'bzr_branch': '',
437+ 'branch_deleted': False,
438 'bname': '',
439 'commit': '',
440 'distro_series': [],
441@@ -268,6 +269,12 @@
442 return "lp:%s" % self.branch_spec
443
444 @property
445+ def branch_deleted(self):
446+ """True if the Launchpad branch of this charm is deleted, else False.
447+ """
448+ return self._representation['branch_deleted']
449+
450+ @property
451 def owner(self):
452 """The owner of the charm's branch.'
453
454
455=== modified file 'charmworld/testing/factory.py'
456--- charmworld/testing/factory.py 2013-05-29 19:19:19 +0000
457+++ charmworld/testing/factory.py 2013-06-10 14:27:33 +0000
458@@ -120,7 +120,8 @@
459 commit_message='maintainer', provides=None, requires=None,
460 options=None, files=None, charm_error=False,
461 date_created=None, downloads_in_past_30_days=0,
462- is_featured=False, promulgated=False, categories=None):
463+ is_featured=False, promulgated=False, categories=None,
464+ branch_deleted=False):
465 """Return the json of a charm."""
466 if not description:
467 description = """byobu-class provides remote terminal access through
468@@ -188,6 +189,7 @@
469 charm = {
470 "bname": bname,
471 "branch_dir": os.path.join(branch_root, name),
472+ "branch_deleted": branch_deleted,
473 "branch_spec": "~%s/charms/%s/%s/trunk" % (owner, series, name),
474 "categories": categories,
475 "changes": changes,

Subscribers

People subscribed via source and target branches