← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-repository-ui-edit-target into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-repository-ui-edit-target into lp:launchpad with lp:~cjwatson/launchpad/git-repository-ui-edit-owner as a prerequisite.

Commit message:
Teach GitRepository:+edit how to change target, target_default, and owner_default.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-repository-ui-edit-target/+merge/261232

Teach GitRepository:+edit how to change target, target_default, and owner_default.  This involves some JavaScript to disable the *_default fields when the target is set to a personal repository, where they don't make sense.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-repository-ui-edit-target into lp:launchpad.
=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml	2015-06-05 09:08:50 +0000
+++ lib/lp/code/browser/configure.zcml	2015-06-05 14:00:28 +0000
@@ -799,7 +799,7 @@
         class="lp.code.browser.gitrepository.GitRepositoryEditView"
         permission="launchpad.Edit"
         name="+edit"
-        template="../../app/templates/generic-edit.pt"/>
+        template="../templates/gitrepository-edit.pt"/>
     <browser:page
         for="lp.code.interfaces.gitrepository.IGitRepository"
         class="lp.code.browser.gitrepository.GitRepositoryDeletionView"

=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py	2015-06-05 14:00:28 +0000
+++ lib/lp/code/browser/gitrepository.py	2015-06-05 14:00:28 +0000
@@ -47,13 +47,18 @@
 from lp.app.errors import NotFoundError
 from lp.app.vocabularies import InformationTypeVocabulary
 from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
+from lp.code.browser.widgets.gitrepositorytarget import (
+    GitRepositoryTargetWidget,
+    )
 from lp.code.errors import (
     GitRepositoryCreationForbidden,
     GitRepositoryExists,
+    GitTargetError,
     )
 from lp.code.interfaces.gitnamespace import get_git_namespace
 from lp.code.interfaces.gitref import IGitRefBatchNavigator
 from lp.code.interfaces.gitrepository import IGitRepository
+from lp.registry.interfaces.person import IPerson
 from lp.registry.vocabularies import UserTeamsParticipationPlusSelfVocabulary
 from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
@@ -277,7 +282,12 @@
                 vocabulary=InformationTypeVocabulary(types=info_types))
             name = copy_field(IGitRepository["name"], readonly=False)
             owner = copy_field(IGitRepository["owner"], readonly=False)
+            owner_default = copy_field(
+                IGitRepository["owner_default"], readonly=False)
             reviewer = copy_field(IGitRepository["reviewer"], required=True)
+            target = copy_field(IGitRepository["target"], readonly=False)
+            target_default = copy_field(
+                IGitRepository["target_default"], readonly=False)
 
         return GitRepositoryEditSchema
 
@@ -322,6 +332,25 @@
             information_type = data.pop("information_type")
             self.context.transitionToInformationType(
                 information_type, self.user)
+        if "target" in data:
+            target = data.pop("target")
+            if target is None:
+                target = self.context.owner
+            if target != self.context.target:
+                try:
+                    self.context.setTarget(target, self.user)
+                except GitTargetError as e:
+                    self.setFieldError("target", e.message)
+                    return
+                changed = True
+                if IPerson.providedBy(target):
+                    self.request.response.addNotification(
+                        "This repository is now a personal repository for %s "
+                        "(%s)" % (target.displayname, target.name))
+                else:
+                    self.request.response.addNotification(
+                        "The repository target has been changed to %s (%s)" %
+                        (target.displayname, target.name))
         if "reviewer" in data:
             reviewer = data.pop("reviewer")
             if reviewer != self.context.code_reviewer:
@@ -331,6 +360,16 @@
                 else:
                     self.context.reviewer = reviewer
                 changed = True
+        if "target_default" in data:
+            target_default = data.pop("target_default")
+            if (self.context.namespace.has_defaults and
+                    target_default != self.context.target_default):
+                self.context.setTargetDefault(target_default)
+        if "owner_default" in data:
+            owner_default = data.pop("owner_default")
+            if (self.context.namespace.has_defaults and
+                    owner_default != self.context.owner_default):
+                self.context.setOwnerDefault(owner_default)
 
         if self.updateContextFromData(data, notify_modified=False):
             changed = True
@@ -383,9 +422,13 @@
     field_names = [
         "owner",
         "name",
+        "target",
         "information_type",
+        "target_default",
+        "owner_default",
         ]
 
+    custom_widget("target", GitRepositoryTargetWidget)
     custom_widget("information_type", LaunchpadRadioWidgetWithDescription)
 
     def setUpFields(self):
@@ -442,14 +485,15 @@
         self.setFieldError(field_name, message)
 
     def validate(self, data):
-        if "name" in data and "owner" in data:
+        if "name" in data and "owner" in data and "target" in data:
             name = data["name"]
             owner = data["owner"]
-            if name != self.context.name or owner != self.context.owner:
-                if self.context.owner == self.context.target:
-                    target = owner
-                else:
-                    target = self.context.target
+            target = data["target"]
+            if target is None:
+                target = owner
+            if (name != self.context.name or
+                    owner != self.context.owner or
+                    target != self.context.target):
                 namespace = get_git_namespace(target, owner)
                 try:
                     namespace.validateMove(self.context, self.user, name=name)

=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
--- lib/lp/code/browser/tests/test_gitrepository.py	2015-06-05 14:00:28 +0000
+++ lib/lp/code/browser/tests/test_gitrepository.py	2015-06-05 14:00:28 +0000
@@ -38,7 +38,10 @@
     TestCaseWithFactory,
     )
 from lp.testing.layers import DatabaseFunctionalLayer
-from lp.testing.matchers import HasQueryCount
+from lp.testing.matchers import (
+    Contains,
+    HasQueryCount,
+    )
 from lp.testing.pages import (
     get_feedback_messages,
     setupBrowser,
@@ -255,6 +258,171 @@
 
     layer = DatabaseFunctionalLayer
 
+    def test_repository_target_widget_renders_personal(self):
+        # The repository target widget renders correctly for a personal
+        # repository.
+        person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            owner=person, target=person)
+        login_person(person)
+        view = create_initialized_view(repository, name="+edit")
+        self.assertEqual("personal", view.widgets["target"].default_option)
+
+    def test_repository_target_widget_renders_product(self):
+        # The repository target widget renders correctly for a product
+        # repository.
+        person = self.factory.makePerson()
+        project = self.factory.makeProduct()
+        repository = self.factory.makeGitRepository(
+            owner=person, target=project)
+        login_person(person)
+        view = create_initialized_view(repository, name="+edit")
+        self.assertEqual("project", view.widgets["target"].default_option)
+        self.assertEqual(
+            project.name, view.widgets["target"].project_widget.selected_value)
+
+    def test_repository_target_widget_renders_package(self):
+        # The repository target widget renders correctly for a package
+        # repository.
+        person = self.factory.makePerson()
+        dsp = self.factory.makeDistributionSourcePackage()
+        repository = self.factory.makeGitRepository(owner=person, target=dsp)
+        login_person(person)
+        view = create_initialized_view(repository, name="+edit")
+        self.assertEqual("package", view.widgets["target"].default_option)
+        self.assertEqual(
+            dsp.distribution,
+            view.widgets["target"].distribution_widget._getFormValue())
+        self.assertEqual(
+            dsp.sourcepackagename.name,
+            view.widgets["target"].package_widget.selected_value)
+
+    def test_repository_target_widget_saves_personal(self):
+        # The repository target widget can retarget to a personal
+        # repository.
+        person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=person)
+        login_person(person)
+        form = {
+            "field.target": "personal",
+            "field.actions.change": "Change Git Repository",
+            }
+        view = create_initialized_view(repository, name="+edit", form=form)
+        self.assertEqual(person, repository.target)
+        self.assertEqual(1, len(view.request.response.notifications))
+        self.assertEqual(
+            "This repository is now a personal repository for %s (%s)"
+                % (person.displayname, person.name),
+            view.request.response.notifications[0].message)
+
+    def test_repository_target_widget_saves_personal_different_owner(self):
+        # The repository target widget can retarget to a personal repository
+        # for a different owner.
+        person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            owner=person, target=person)
+        new_owner = self.factory.makeTeam(name="newowner", members=[person])
+        login_person(person)
+        form = {
+            "field.target": "personal",
+            "field.owner": "newowner",
+            "field.actions.change": "Change Git Repository",
+            }
+        view = create_initialized_view(repository, name="+edit", form=form)
+        self.assertEqual(new_owner, repository.target)
+        self.assertEqual(1, len(view.request.response.notifications))
+        self.assertEqual(
+            "The repository owner has been changed to Newowner (newowner)",
+            view.request.response.notifications[0].message)
+
+    def test_repository_target_widget_saves_personal_clears_defaults(self):
+        # When retargeting to a personal repository, the target and
+        # owner-target default flags are cleared.
+        person = self.factory.makePerson()
+        project = self.factory.makeProduct(owner=person)
+        repository = self.factory.makeGitRepository(
+            owner=person, target=project)
+        login_person(person)
+        repository.setTargetDefault(True)
+        repository.setOwnerDefault(True)
+        form = {
+            "field.target": "personal",
+            "field.actions.change": "Change Git Repository",
+            }
+        view = create_initialized_view(repository, name="+edit", form=form)
+        self.assertEqual([], view.errors)
+        self.assertEqual(person, repository.target)
+        self.assertEqual(1, len(view.request.response.notifications))
+        self.assertEqual(
+            "This repository is now a personal repository for %s (%s)"
+                % (person.displayname, person.name),
+            view.request.response.notifications[0].message)
+
+    def test_repository_target_widget_saves_project(self):
+        # The repository target widget can retarget to a project repository.
+        person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            owner=person, target=person)
+        project = self.factory.makeProduct()
+        login_person(person)
+        form = {
+            "field.target": "project",
+            "field.target.project": project.name,
+            "field.actions.change": "Change Git Repository",
+            }
+        view = create_initialized_view(repository, name="+edit", form=form)
+        self.assertEqual(project, repository.target)
+        self.assertEqual(
+            "The repository target has been changed to %s (%s)"
+                % (project.displayname, project.name),
+            view.request.response.notifications[0].message)
+
+    def test_repository_target_widget_saves_package(self):
+        # The repository target widget can retarget to a package repository.
+        person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            owner=person, target=person)
+        dsp = self.factory.makeDistributionSourcePackage()
+        self.factory.makeSourcePackagePublishingHistory(
+            distroseries=dsp.distribution.currentseries,
+            sourcepackagename=dsp.sourcepackagename,
+            archive=dsp.distribution.main_archive)
+        login_person(person)
+        form = {
+            "field.target": "package",
+            "field.target.distribution": dsp.distribution.name,
+            "field.target.package": dsp.sourcepackagename.name,
+            "field.actions.change": "Change Git Repository",
+            }
+        view = create_initialized_view(repository, name="+edit", form=form)
+        self.assertEqual(dsp, repository.target)
+        self.assertEqual(
+            "The repository target has been changed to %s (%s)"
+                % (dsp.displayname, dsp.name),
+            view.request.response.notifications[0].message)
+
+    def test_forbidden_target_is_error(self):
+        # An error is displayed if a repository is saved with a target that
+        # is not allowed by the sharing policy.
+        owner = self.factory.makePerson()
+        initial_target = self.factory.makeProduct()
+        self.factory.makeProduct(
+            name="commercial", owner=owner,
+            branch_sharing_policy=BranchSharingPolicy.PROPRIETARY)
+        repository = self.factory.makeGitRepository(
+            owner=owner, target=initial_target,
+            information_type=InformationType.PUBLIC)
+        browser = self.getUserBrowser(
+            canonical_url(repository) + "/+edit", user=owner)
+        browser.getControl(name="field.target.project").value = "commercial"
+        browser.getControl("Change Git Repository").click()
+        self.assertThat(
+            browser.contents,
+            Contains(
+                "Public repositories are not allowed for target Commercial."))
+        with person_logged_in(owner):
+            self.assertEqual(initial_target, repository.target)
+
     def test_rename(self):
         # The name of a repository can be changed via the UI by an
         # authorised user.

=== added file 'lib/lp/code/browser/widgets/gitrepositorytarget.py'
--- lib/lp/code/browser/widgets/gitrepositorytarget.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/gitrepositorytarget.py	2015-06-05 14:00:28 +0000
@@ -0,0 +1,194 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+__all__ = [
+    'GitRepositoryTargetWidget',
+    ]
+
+from z3c.ptcompat import ViewPageTemplateFile
+from zope.component import getUtility
+from zope.formlib.interfaces import (
+    ConversionError,
+    IInputWidget,
+    InputErrors,
+    MissingInputError,
+    WidgetInputError,
+    )
+from zope.formlib.utility import setUpWidget
+from zope.formlib.widget import (
+    BrowserWidget,
+    CustomWidgetFactory,
+    InputWidget,
+    renderElement,
+    )
+from zope.interface import implements
+from zope.schema import Choice
+
+from lp.app.errors import (
+    NotFoundError,
+    UnexpectedFormData,
+    )
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.app.validators import LaunchpadValidationError
+from lp.app.widgets.itemswidgets import LaunchpadDropdownWidget
+from lp.registry.interfaces.distributionsourcepackage import (
+    IDistributionSourcePackage,
+    )
+from lp.registry.interfaces.person import IPerson
+from lp.registry.interfaces.product import IProduct
+from lp.services.webapp.interfaces import (
+    IAlwaysSubmittedWidget,
+    IMultiLineWidgetLayout,
+    )
+
+
+class GitRepositoryTargetWidget(BrowserWidget, InputWidget):
+    """Widget for selecting a Git repository target."""
+
+    implements(IAlwaysSubmittedWidget, IMultiLineWidgetLayout, IInputWidget)
+
+    template = ViewPageTemplateFile("templates/gitrepository-target.pt")
+    default_option = "project"
+    _widgets_set_up = False
+
+    def setUpSubWidgets(self):
+        if self._widgets_set_up:
+            return
+        fields = [
+            Choice(
+                __name__="project", title=u"Project",
+                required=True, vocabulary="Product"),
+            Choice(
+                __name__="distribution", title=u"Distribution",
+                required=True, vocabulary="Distribution",
+                default=getUtility(ILaunchpadCelebrities).ubuntu),
+            Choice(
+                __name__="package", title=u"Package",
+                required=False, vocabulary="BinaryAndSourcePackageName"),
+            ]
+        self.distribution_widget = CustomWidgetFactory(LaunchpadDropdownWidget)
+        for field in fields:
+            setUpWidget(
+                self, field.__name__, field, IInputWidget, prefix=self.name)
+        self._widgets_set_up = True
+
+    def setUpOptions(self):
+        """Set up options to be rendered."""
+        self.options = {}
+        for option in ["personal", "package", "project"]:
+            attributes = dict(
+                type="radio", name=self.name, value=option,
+                id="%s.option.%s" % (self.name, option))
+            if self.request.form_ng.getOne(
+                     self.name, self.default_option) == option:
+                attributes["checked"] = "checked"
+            self.options[option] = renderElement("input", **attributes)
+        self.package_widget.onKeyPress = (
+            "selectWidget('%s.option.package', event)" % self.name)
+        self.project_widget.onKeyPress = (
+            "selectWidget('%s.option.project', event)" % self.name)
+
+    def hasInput(self):
+        return self.name in self.request.form
+
+    def hasValidInput(self):
+        """See `zope.formlib.interfaces.IInputWidget`."""
+        try:
+            self.getInputValue()
+            return True
+        except (InputErrors, UnexpectedFormData):
+            return False
+
+    def getInputValue(self):
+        """See `zope.formlib.interfaces.IInputWidget`."""
+        self.setUpSubWidgets()
+        form_value = self.request.form_ng.getOne(self.name)
+        if form_value == "project":
+            try:
+                return self.project_widget.getInputValue()
+            except MissingInputError:
+                raise WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError("Please enter a project name"))
+            except ConversionError:
+                entered_name = self.request.form_ng.getOne(
+                    "%s.project" % self.name)
+                raise WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError(
+                        "There is no project named '%s' registered in "
+                        "Launchpad" % entered_name))
+        elif form_value == "package":
+            try:
+                distribution = self.distribution_widget.getInputValue()
+            except ConversionError:
+                entered_name = self.request.form_ng.getOne(
+                    "%s.distribution" % self.name)
+                raise WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError(
+                        "There is no distribution named '%s' registered in "
+                        "Launchpad" % entered_name))
+            try:
+                if self.package_widget.hasInput():
+                    package_name = self.package_widget.getInputValue()
+                else:
+                    package_name = None
+                if package_name is None:
+                    raise WidgetInputError(
+                        self.name, self.label,
+                        LaunchpadValidationError(
+                            "Please enter a package name"))
+                if IDistributionSourcePackage.providedBy(package_name):
+                    dsp = package_name
+                else:
+                    source_name = distribution.guessPublishedSourcePackageName(
+                        package_name.name)
+                    dsp = distribution.getSourcePackage(source_name)
+            except (ConversionError, NotFoundError):
+                entered_name = self.request.form_ng.getOne(
+                    "%s.package" % self.name)
+                raise WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError(
+                        "There is no package named '%s' published in %s." %
+                        (entered_name, distribution.displayname)))
+            return dsp
+        elif form_value == "personal":
+            return None
+        else:
+            raise UnexpectedFormData("No valid option was selected.")
+
+    def setRenderedValue(self, value):
+        """See `IWidget`."""
+        self.setUpSubWidgets()
+        if value is None or IPerson.providedBy(value):
+            self.default_option = "personal"
+            return
+        elif IProduct.providedBy(value):
+            self.default_option = "project"
+            self.project_widget.setRenderedValue(value)
+            return
+        elif IDistributionSourcePackage.providedBy(value):
+            self.default_option = "package"
+            self.distribution_widget.setRenderedValue(value.distribution)
+            self.package_widget.setRenderedValue(value.sourcepackagename)
+        else:
+            raise AssertionError("Not a valid value: %r" % value)
+
+    def error(self):
+        """See `zope.formlib.interfaces.IBrowserWidget`."""
+        try:
+            if self.hasInput():
+                self.getInputValue()
+        except InputErrors as error:
+            self._error = error
+        return super(GitRepositoryTargetWidget, self).error()
+
+    def __call__(self):
+        """See `zope.formlib.interfaces.IBrowserWidget`."""
+        self.setUpSubWidgets()
+        self.setUpOptions()
+        return self.template()

=== added file 'lib/lp/code/browser/widgets/templates/gitrepository-target.pt'
--- lib/lp/code/browser/widgets/templates/gitrepository-target.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/templates/gitrepository-target.pt	2015-06-05 14:00:28 +0000
@@ -0,0 +1,50 @@
+<table>
+  <tr>
+    <td colspan="2">
+      <label>
+        <input
+            type="radio" value="personal"
+            tal:replace="structure view/options/personal" />
+        Personal
+      </label>
+    </td>
+  </tr>
+
+  <tr>
+    <td>
+      <label>
+        <input
+            type="radio" value="package"
+            tal:replace="structure view/options/package" />
+        Distribution
+      </label>
+    </td>
+    <td>
+      <tal:distribution tal:replace="structure view/distribution_widget" />
+    </td>
+  </tr>
+  <tr>
+    <td align="right">
+      <label tal:attributes="for string:${view/name}.option.package">
+        Package
+      </label>
+    </td>
+    <td>
+      <tal:package tal:replace="structure view/package_widget" />
+    </td>
+  </tr>
+
+  <tr>
+    <td>
+      <label>
+        <input
+            type="radio" value="project"
+            tal:replace="structure view/options/project" />
+       Project
+      </label>
+    </td>
+    <td>
+      <tal:product tal:replace="structure view/project_widget" />
+    </td>
+  </tr>
+</table>

=== added file 'lib/lp/code/browser/widgets/tests/test_gitrepositorytargetwidget.py'
--- lib/lp/code/browser/widgets/tests/test_gitrepositorytargetwidget.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/widgets/tests/test_gitrepositorytargetwidget.py	2015-06-05 14:00:28 +0000
@@ -0,0 +1,370 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+import re
+
+from BeautifulSoup import BeautifulSoup
+from lazr.restful.fields import Reference
+from zope.formlib.interfaces import (
+    IBrowserWidget,
+    IInputWidget,
+    WidgetInputError,
+    )
+from zope.interface import (
+    implements,
+    Interface,
+    )
+
+from lp.app.validators import LaunchpadValidationError
+from lp.code.browser.widgets.gitrepositorytarget import (
+    GitRepositoryTargetWidget,
+    )
+from lp.registry.vocabularies import (
+    DistributionVocabulary,
+    ProductVocabulary,
+    )
+from lp.services.webapp.escaping import html_escape
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.soyuz.model.binaryandsourcepackagename import (
+    BinaryAndSourcePackageNameVocabulary,
+    )
+from lp.testing import (
+    TestCaseWithFactory,
+    verifyObject,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class IThing(Interface):
+    owner = Reference(schema=Interface)
+    target = Reference(schema=Interface)
+
+
+class Thing:
+    implements(IThing)
+    owner = None
+    target = None
+
+
+class TestGitRepositoryTargetWidget(TestCaseWithFactory):
+    """Test the GitRepositoryTargetWidget class."""
+
+    layer = DatabaseFunctionalLayer
+
+    @property
+    def form(self):
+        return {
+            "field.target": "project",
+            "field.target.distribution": "fnord",
+            "field.target.package": "snarf",
+            "field.target.project": "pting",
+            }
+
+    def setUp(self):
+        super(TestGitRepositoryTargetWidget, self).setUp()
+        self.distribution, self.package = self.factory.makeDSPCache(
+            distro_name="fnord", package_name="snarf")
+        self.project = self.factory.makeProduct("pting")
+        field = Reference(
+            __name__="target", schema=Interface, title=u"target")
+        self.context = Thing()
+        field = field.bind(self.context)
+        request = LaunchpadTestRequest()
+        self.widget = GitRepositoryTargetWidget(field, request)
+
+    def test_implements(self):
+        self.assertTrue(verifyObject(IBrowserWidget, self.widget))
+        self.assertTrue(verifyObject(IInputWidget, self.widget))
+
+    def test_template(self):
+        # The render template is setup.
+        self.assertTrue(
+            self.widget.template.filename.endswith("gitrepository-target.pt"),
+            "Template was not setup.")
+
+    def test_default_option(self):
+        # This project field is the default option.
+        self.assertEqual("project", self.widget.default_option)
+
+    def test_hasInput_false(self):
+        # hasInput is false when the widget's name is not in the form data.
+        self.widget.request = LaunchpadTestRequest(form={})
+        self.assertEqual("field.target", self.widget.name)
+        self.assertFalse(self.widget.hasInput())
+
+    def test_hasInput_true(self):
+        # hasInput is true is the widget's name in the form data.
+        self.widget.request = LaunchpadTestRequest(form=self.form)
+        self.assertEqual("field.target", self.widget.name)
+        self.assertTrue(self.widget.hasInput())
+
+    def test_setUpSubWidgets_first_call(self):
+        # The subwidgets are setup and a flag is set.
+        self.widget.setUpSubWidgets()
+        self.assertTrue(self.widget._widgets_set_up)
+        self.assertIsInstance(
+            self.widget.distribution_widget.context.vocabulary,
+            DistributionVocabulary)
+        self.assertIsInstance(
+            self.widget.package_widget.context.vocabulary,
+            BinaryAndSourcePackageNameVocabulary)
+        self.assertIsInstance(
+            self.widget.project_widget.context.vocabulary,
+            ProductVocabulary)
+
+    def test_setUpSubWidgets_second_call(self):
+        # The setUpSubWidgets method exits early if a flag is set to
+        # indicate that the widgets were setup.
+        self.widget._widgets_set_up = True
+        self.widget.setUpSubWidgets()
+        self.assertIsNone(getattr(self.widget, "distribution_widget", None))
+        self.assertIsNone(getattr(self.widget, "package_widget", None))
+        self.assertIsNone(getattr(self.widget, "project_widget", None))
+
+    def test_setUpOptions_default_project_checked(self):
+        # The radio button options are composed of the setup widgets with
+        # the project widget set as the default.
+        self.widget.setUpSubWidgets()
+        self.widget.setUpOptions()
+        self.assertEqual(
+            "selectWidget('field.target.option.package', event)",
+            self.widget.package_widget.onKeyPress)
+        self.assertEqual(
+            "selectWidget('field.target.option.project', event)",
+            self.widget.project_widget.onKeyPress)
+        self.assertEqual(
+            '<input class="radioType" '
+            'id="field.target.option.personal" name="field.target" '
+            'type="radio" value="personal" />',
+            self.widget.options["personal"])
+        self.assertEqual(
+            '<input class="radioType" '
+            'id="field.target.option.package" name="field.target" '
+            'type="radio" value="package" />',
+            self.widget.options["package"])
+        self.assertEqual(
+            '<input class="radioType" checked="checked" '
+            'id="field.target.option.project" name="field.target" '
+            'type="radio" value="project" />',
+            self.widget.options["project"])
+
+    def test_setUpOptions_personal_checked(self):
+        # The personal radio button is selected when the form is submitted
+        # when the target field's value is 'personal'.
+        form = {
+            "field.target": "personal",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.widget.setUpSubWidgets()
+        self.widget.setUpOptions()
+        self.assertEqual(
+            '<input class="radioType" checked="checked" '
+            'id="field.target.option.personal" name="field.target" '
+            'type="radio" value="personal" />',
+            self.widget.options["personal"])
+        self.assertEqual(
+            '<input class="radioType" '
+            'id="field.target.option.package" name="field.target" '
+            'type="radio" value="package" />',
+            self.widget.options["package"])
+        self.assertEqual(
+            '<input class="radioType" '
+            'id="field.target.option.project" name="field.target" '
+            'type="radio" value="project" />',
+            self.widget.options["project"])
+
+    def test_setUpOptions_package_checked(self):
+        # The package radio button is selected when the form is submitted
+        # when the target field's value is 'package'.
+        form = {
+            "field.target": "package",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.widget.setUpSubWidgets()
+        self.widget.setUpOptions()
+        self.assertEqual(
+            '<input class="radioType" '
+            'id="field.target.option.personal" name="field.target" '
+            'type="radio" value="personal" />',
+            self.widget.options["personal"])
+        self.assertEqual(
+            '<input class="radioType" checked="checked" '
+            'id="field.target.option.package" name="field.target" '
+            'type="radio" value="package" />',
+            self.widget.options["package"])
+        self.assertEqual(
+            '<input class="radioType" '
+            'id="field.target.option.project" name="field.target" '
+            'type="radio" value="project" />',
+            self.widget.options["project"])
+
+    def test_setUpOptions_project_checked(self):
+        # The project radio button is selected when the form is submitted
+        # when the target field's value is 'project'.
+        form = {
+            "field.target": "project",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.widget.setUpSubWidgets()
+        self.widget.setUpOptions()
+        self.assertEqual(
+            '<input class="radioType" '
+            'id="field.target.option.personal" name="field.target" '
+            'type="radio" value="personal" />',
+            self.widget.options["personal"])
+        self.assertEqual(
+            '<input class="radioType" '
+            'id="field.target.option.package" name="field.target" '
+            'type="radio" value="package" />',
+            self.widget.options["package"])
+        self.assertEqual(
+            '<input class="radioType" checked="checked" '
+            'id="field.target.option.project" name="field.target" '
+            'type="radio" value="project" />',
+            self.widget.options["project"])
+
+    def test_hasValidInput_true(self):
+        # The field input is valid when all submitted parts are valid.
+        self.widget.request = LaunchpadTestRequest(form=self.form)
+        self.assertTrue(self.widget.hasValidInput())
+
+    def test_hasValidInput_false(self):
+        # The field input is invalid if any of the submitted parts are invalid.
+        form = self.form
+        form["field.target.project"] = "non-existent"
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertFalse(self.widget.hasValidInput())
+
+    def test_getInputValue_personal(self):
+        # The field value is None when the personal radio button is
+        # selected.
+        form = self.form
+        form["field.target"] = "personal"
+        self.context.owner = self.factory.makePerson()
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertIsNone(self.widget.getInputValue())
+
+    def test_getInputValue_package_spn(self):
+        # The field value is the package when the package radio button
+        # is selected and the package sub field has official spn.
+        form = self.form
+        form["field.target"] = "package"
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertEqual(self.package, self.widget.getInputValue())
+
+    def test_getInputValue_package_invalid(self):
+        # An error is raised when the package is not published in the distro.
+        form = self.form
+        form["field.target"] = "package"
+        form["field.target.package"] = 'non-existent'
+        self.widget.request = LaunchpadTestRequest(form=form)
+        message = (
+            "There is no package named 'non-existent' published in Fnord.")
+        e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
+        self.assertEqual(LaunchpadValidationError(message), e.errors)
+        self.assertEqual(html_escape(message), self.widget.error())
+
+    def test_getInputValue_distribution(self):
+        # An error is raised when the package radio button is selected and
+        # the package sub field is empty.
+        form = self.form
+        form["field.target"] = "package"
+        form["field.target.package"] = ''
+        self.widget.request = LaunchpadTestRequest(form=form)
+        message = "Please enter a package name"
+        e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
+        self.assertEqual(LaunchpadValidationError(message), e.errors)
+        self.assertEqual(message, self.widget.error())
+
+    def test_getInputValue_distribution_invalid(self):
+        # An error is raised when the distribution is invalid.
+        form = self.form
+        form["field.target"] = "package"
+        form["field.target.package"] = ''
+        form["field.target.distribution"] = 'non-existent'
+        self.widget.request = LaunchpadTestRequest(form=form)
+        message = (
+            "There is no distribution named 'non-existent' registered in "
+            "Launchpad")
+        e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
+        self.assertEqual(LaunchpadValidationError(message), e.errors)
+        self.assertEqual(html_escape(message), self.widget.error())
+
+    def test_getInputValue_project(self):
+        # The field value is the project when the project radio button is
+        # selected and the project sub field is valid.
+        form = self.form
+        form["field.target"] = "project"
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertEqual(self.project, self.widget.getInputValue())
+
+    def test_getInputValue_project_missing(self):
+        # An error is raised when the project field is missing.
+        form = self.form
+        form["field.target"] = "project"
+        del form["field.target.project"]
+        self.widget.request = LaunchpadTestRequest(form=form)
+        message = "Please enter a project name"
+        e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
+        self.assertEqual(LaunchpadValidationError(message), e.errors)
+        self.assertEqual(message, self.widget.error())
+
+    def test_getInputValue_project_invalid(self):
+        # An error is raised when the project is not valid.
+        form = self.form
+        form["field.target"] = "project"
+        form["field.target.project"] = "non-existent"
+        self.widget.request = LaunchpadTestRequest(form=form)
+        message = (
+            "There is no project named 'non-existent' registered in "
+            "Launchpad")
+        e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
+        self.assertEqual(LaunchpadValidationError(message), e.errors)
+        self.assertEqual(html_escape(message), self.widget.error())
+
+    def test_setRenderedValue_personal(self):
+        # Passing a person will set the widget's render state to 'personal'.
+        self.widget.setUpSubWidgets()
+        self.widget.setRenderedValue(self.factory.makePerson())
+        self.assertEqual("personal", self.widget.default_option)
+
+    def test_setRenderedValue_package(self):
+        # Passing a package will set the widget's render state to 'package'.
+        self.widget.setUpSubWidgets()
+        self.widget.setRenderedValue(self.package)
+        self.assertEqual("package", self.widget.default_option)
+        self.assertEqual(
+            self.distribution,
+            self.widget.distribution_widget._getCurrentValue())
+        self.assertEqual(
+            self.package.sourcepackagename,
+            self.widget.package_widget._getCurrentValue())
+
+    def test_setRenderedValue_project(self):
+        # Passing a project will set the widget's render state to 'project'.
+        self.widget.setUpSubWidgets()
+        self.widget.setRenderedValue(self.project)
+        self.assertEqual("project", self.widget.default_option)
+        self.assertEqual(
+            self.project, self.widget.project_widget._getCurrentValue())
+
+    def test_call(self):
+        # The __call__ method setups the widgets and the options.
+        markup = self.widget()
+        self.assertIsNotNone(self.widget.project_widget)
+        self.assertIn("personal", self.widget.options)
+        self.assertIn("package", self.widget.options)
+        expected_ids = [
+            "field.target.distribution",
+            "field.target.option.personal",
+            "field.target.option.package",
+            "field.target.option.project",
+            "field.target.package",
+            "field.target.project",
+            ]
+        soup = BeautifulSoup(markup)
+        fields = soup.findAll(["input", "select"], {"id": re.compile(".*")})
+        ids = [field["id"] for field in fields]
+        self.assertContentEqual(expected_ids, ids)

=== added file 'lib/lp/code/javascript/gitrepository.edit.js'
--- lib/lp/code/javascript/gitrepository.edit.js	1970-01-01 00:00:00 +0000
+++ lib/lp/code/javascript/gitrepository.edit.js	2015-06-05 14:00:28 +0000
@@ -0,0 +1,34 @@
+/* Copyright 2015 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Control enabling/disabling form elements on the GitRepository:+edit page.
+ *
+ * @module Y.lp.code.gitrepository.edit
+ * @requires node, DOM
+ */
+YUI.add('lp.code.gitrepository.edit', function(Y) {
+    Y.log('loading lp.code.gitrepository.edit');
+    var module = Y.namespace('lp.code.gitrepository.edit');
+
+    module.set_enabled = function(field_id, is_enabled) {
+        var field = Y.DOM.byId(field_id);
+        field.disabled = !is_enabled;
+    };
+
+    module.onclick_target = function(e) {
+        var value = false;
+        Y.all('input[name="field.target"]').each(function(node) {
+            if (node.get('checked'))
+                value = node.get('value');
+        });
+        module.set_enabled('field.target_default', value !== 'personal');
+        module.set_enabled('field.owner_default', value !== 'personal');
+    };
+
+    module.setup = function() {
+        Y.all('input[name="field.target"]').on('click', module.onclick_target);
+
+        // Set the initial state.
+        module.onclick_target();
+    };
+}, '0.1', {'requires': ['node', 'DOM']});

=== added file 'lib/lp/code/javascript/tests/test_gitrepository.edit.html'
--- lib/lp/code/javascript/tests/test_gitrepository.edit.html	1970-01-01 00:00:00 +0000
+++ lib/lp/code/javascript/tests/test_gitrepository.edit.html	2015-06-05 14:00:28 +0000
@@ -0,0 +1,167 @@
+<!DOCTYPE html>
+<!--
+Copyright 2015 Canonical Ltd.  This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+  <head>
+      <title>code.gitrepository.edit Tests</title>
+
+      <!-- YUI and test setup -->
+      <script type="text/javascript"
+              src="../../../../../build/js/yui/yui/yui.js">
+      </script>
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/console/assets/console-core.css" />
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" />
+      <link rel="stylesheet"
+      href="../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+      <script type="text/javascript"
+              src="../../../../../build/js/lp/app/testing/testrunner.js"></script>
+
+      <link rel="stylesheet" href="../../../app/javascript/testing/test.css" />
+
+      <!-- Dependencies -->
+      <script type="text/javascript"
+          src="../../../../../build/js/lp/app/lp.js"></script>
+
+      <!-- The module under test. -->
+      <script type="text/javascript" src="../gitrepository.edit.js"></script>
+
+      <!-- The test suite -->
+      <script type="text/javascript" src="test_gitrepository.edit.js"></script>
+
+      <script type="text/javascript">
+        YUI().use('lp.code.gitrepository.edit', function(Y) {
+          Y.on('domready', Y.lp.code.gitrepository.edit.setup);
+        });
+      </script>
+
+    </head>
+    <body class="yui3-skin-sam">
+        <ul id="suites">
+            <li>lp.code.gitrepository.edit.test</li>
+        </ul>
+        <div id="gitrepository.edit">
+        <form action="." name="launchpadform" method="post"
+              enctype="multipart/form-data"
+              accept-charset="UTF-8">
+
+          <table class="form">
+            <tr>
+              <td colspan="2" style="text-align: left">
+                <div>
+                  <label for="field.target">Target:</label>
+                  <div>
+                    <table>
+                      <tr>
+                        <td colspan="2">
+                          <label>
+                            <input class="radioType"
+                                   id="field.target.option.personal"
+                                   name="field.target"
+                                   type="radio"
+                                   value="personal" />
+                            Personal
+                          </label>
+                        </td>
+                      </tr>
+                      <tr>
+                        <td>
+                          <label>
+                            <input class="radioType"
+                                   id="field.target.option.package"
+                                   name="field.target"
+                                   type="radio" value="package" />
+                            Distribution
+                          </label>
+                        </td>
+                        <td>
+                          <select id="field.target.distribution"
+                                  name="field.target.distribution" size="1">
+                            <option value="debian">Debian GNU/Linux</option>
+                            <option value="ubuntu">Ubuntu</option>
+                          </select>
+                        </td>
+                      </tr>
+                      <tr>
+                        <td align="right">
+                          <label for="field.target.option.package">
+                            Package
+                          </label>
+                        </td>
+                        <td>
+                          <input type="text" value="" id="field.target.package"
+                                 name="field.target.package" size="20" />
+                        </td>
+                      </tr>
+                      <tr>
+                        <td>
+                          <label>
+                            <input class="radioType"
+                                   id="field.target.option.project"
+                                   name="field.target"
+                                   type="radio"
+                                   value="project" />
+                            Project
+                          </label>
+                        </td>
+                        <td>
+                          <input type="text" value="" id="field.target.project"
+                                 name="field.target.project" size="20" />
+                        </td>
+                      </tr>
+                    </table>
+                  </div>
+                  <p class="formHelp">The target of the repository.</p>
+                </div>
+              </td>
+            </tr>
+
+            <tr>
+              <td colspan="2">
+                <div>
+                  <input class="hiddenType" id="field.target_default.used"
+                         name="field.target_default.used" type="hidden"
+                         value="" />
+                  <input class="checkboxType" checked="checked"
+                         id="field.target_default" name="field.target_default"
+                         type="checkbox" value="on" />
+                  <label for="field.target_default">Target default</label>
+                  <p class="formHelp">
+                    Whether this repository is the default for its target.
+                  </p>
+                </div>
+              </td>
+            </tr>
+
+            <tr>
+              <td colspan="2">
+                <div>
+                  <input class="hiddenType" id="field.owner_default.used"
+                         name="field.owner_default.used" type="hidden"
+                         value="" />
+                  <input class="checkboxType" checked="checked"
+                         id="field.owner_default" name="field.owner_default"
+                         type="checkbox" value="on" />
+                  <label for="field.owner_default">Owner default</label>
+                  <p class="formHelp">
+                    Whether this repository is the default for its owner.
+                  </p>
+                </div>
+              </td>
+            </tr>
+          </table>
+
+          <input type="submit" id="field.actions.change"
+                 name="field.actions.change" value="Change Git Repository"
+                 class="button" />
+          or&nbsp;
+          <a href="https://code.launchpad.dev/~me/p/+git/r";>Cancel</a>
+        </form>
+        </div>
+    </body>
+</html>

=== added file 'lib/lp/code/javascript/tests/test_gitrepository.edit.js'
--- lib/lp/code/javascript/tests/test_gitrepository.edit.js	1970-01-01 00:00:00 +0000
+++ lib/lp/code/javascript/tests/test_gitrepository.edit.js	2015-06-05 14:00:28 +0000
@@ -0,0 +1,84 @@
+/* Copyright 2015 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Test driver for gitrepository.edit.js.
+ */
+YUI.add('lp.code.gitrepository.edit.test', function(Y) {
+    var tests = Y.namespace('lp.code.gitrepository.edit.test');
+    var module = Y.lp.code.gitrepository.edit;
+    tests.suite = new Y.Test.Suite('code.gitrepository.edit Tests');
+
+    tests.suite.add(new Y.Test.Case({
+        name: 'code.gitrepository.edit_tests',
+
+        setUp: function() {
+            this.tbody = Y.one('#gitrepository.edit');
+
+            // Get the individual target type radio buttons.
+            this.target_personal = Y.DOM.byId('field.target.option.personal');
+            this.target_package = Y.DOM.byId('field.target.option.package');
+            this.target_project = Y.DOM.byId('field.target.option.project');
+
+            // Get the input widgets.
+            this.target_default = Y.DOM.byId('field.target_default');
+            this.owner_default = Y.DOM.byId('field.owner_default');
+        },
+
+        tearDown: function() {
+            delete this.tbody;
+        },
+
+        test_handlers_connected: function() {
+            // Manually invoke the setup function to ensure the handlers are
+            // set.
+            module.setup();
+
+            var check_handler = function(field, expected) {
+                var custom_events = Y.Event.getListeners(field, 'click');
+                var click_event = custom_events[0];
+                var subscribers = click_event.subscribers;
+                Y.each(subscribers, function(sub) {
+                    Y.Assert.isTrue(sub.contains(expected),
+                                    'handler not set up');
+                });
+            };
+
+            check_handler(this.target_personal, module.onclick_target);
+            check_handler(this.target_package, module.onclick_target);
+            check_handler(this.target_project, module.onclick_target);
+        },
+
+        test_select_target_personal: function() {
+            this.target_personal.checked = true;
+            module.onclick_target();
+            // The target_default and owner_default checkboxes are disabled.
+            Y.Assert.isTrue(this.target_default.disabled,
+                            'target_default not disabled');
+            Y.Assert.isTrue(this.owner_default.disabled,
+                            'owner_default not disabled');
+        },
+
+        test_select_target_package: function() {
+            this.target_package.checked = true;
+            module.onclick_target();
+            // The target_default and owner_default checkboxes are enabled.
+            Y.Assert.isFalse(this.target_default.disabled,
+                             'target_default not disabled');
+            Y.Assert.isFalse(this.owner_default.disabled,
+                             'owner_default not disabled');
+        },
+
+        test_select_target_project: function() {
+            this.target_project.checked = true;
+            module.onclick_target();
+            // The target_default and owner_default checkboxes are enabled.
+            Y.Assert.isFalse(this.target_default.disabled,
+                             'target_default not disabled');
+            Y.Assert.isFalse(this.owner_default.disabled,
+                             'owner_default not disabled');
+        },
+    }));
+}, '0.1', {
+    requires: ['lp.testing.runner', 'test', 'test-console', 'Event',
+               'lp.code.gitrepository.edit']
+});

=== added file 'lib/lp/code/templates/gitrepository-edit.pt'
--- lib/lp/code/templates/gitrepository-edit.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-edit.pt	2015-06-05 14:00:28 +0000
@@ -0,0 +1,23 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form" />
+
+  <script type="text/javascript">
+    LPJS.use('lp.code.gitrepository.edit', function(Y) {
+      Y.on('domready', function(e) {
+        Y.lp.code.gitrepository.edit.setup();
+      }, window);
+    });
+  </script>
+</div>
+
+</body>
+</html>


Follow ups