launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #29507
[Merge] ~cjwatson/lpcraft:release-command into lpcraft:main
Colin Watson has proposed merging ~cjwatson/lpcraft:release-command into lpcraft:main.
Commit message:
Add a release command
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/lpcraft/+git/lpcraft/+merge/435280
This integrates with various API endpoints in Launchpad to take the results of a CI build and release them to a properly-configured PPA.
Tests won't pass until https://code.launchpad.net/~cjwatson/launchpadlib/+git/launchpadlib/+merge/435261 and https://code.launchpad.net/~cjwatson/launchpadlib/+git/launchpadlib/+merge/435267 have landed and we've made a new launchpadlib release, though I've tested with a local environment that has those patches applied.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lpcraft:release-command into lpcraft:main.
diff --git a/.mypy.ini b/.mypy.ini
index 60a899d..7a5124e 100644
--- a/.mypy.ini
+++ b/.mypy.ini
@@ -6,7 +6,7 @@ disallow_subclassing_any = false
disallow_untyped_calls = false
disallow_untyped_defs = false
-[mypy-fixtures.*,systemfixtures.*,testtools.*,pluggy.*]
+[mypy-fixtures.*,launchpadlib.*,systemfixtures.*,testtools.*,pluggy.*]
ignore_missing_imports = true
[mypy-craft_cli.*]
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7b6a1bf..dccbc06 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -10,7 +10,9 @@ repos:
- id: check-yaml
- id: debug-statements
- id: end-of-file-fixer
+ exclude: lpcraft/commands/tests/launchpad-wadl\.xml
- id: trailing-whitespace
+ exclude: lpcraft/commands/tests/launchpad-wadl\.xml
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
diff --git a/NEWS.rst b/NEWS.rst
index 2b01545..6cbc395 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -2,11 +2,13 @@
Version history
===============
-0.0.40 (unreleased)
-===================
+0.1.0 (unreleased)
+==================
- Fix the leakage of package repositories from a job to the next
+- Add a ``release`` command.
+
0.0.39 (2023-01-06)
===================
diff --git a/docs/cli-interface.rst b/docs/cli-interface.rst
index afb6907..f30d37e 100644
--- a/docs/cli-interface.rst
+++ b/docs/cli-interface.rst
@@ -91,3 +91,32 @@ lpcraft run-one optional arguments
``lpcraft run-one --set-env="PIP_INDEX_URL=http://pypi.example.com/simple" test 0``
This option is repeatable.
+
+lpcraft release
+---------------
+
+This command releases a Launchpad build of a commit to a target archive
+(which must be configured with a repository format that accepts packages of
+the appropriate type). It checks that the commit in question was
+successfully built and has some attached files.
+
+**Example:**
+
+``lpcraft release ppa:ubuntu-security/soss/soss-python-stable-local focal edge``
+
+lpcraft release optional arguments
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+- ``--launchpad INSTANCE`` to use a Launchpad instance other than
+ production.
+
+- ``--dry-run`` to just report what would be done rather than actually
+ performing a release.
+
+- ``--repository URL`` to specify the source Git repository URL (defaults to
+ the upstream repository for the current branch, if on
+ ``git.launchpad.net``).
+
+- ``--commit ID`` to specify the source Git branch name, tag name, or commit
+ ID (defaults to the tip commit found for the current branch in the
+ upstream repository).
diff --git a/lpcraft/commands/release.py b/lpcraft/commands/release.py
new file mode 100644
index 0000000..22651b5
--- /dev/null
+++ b/lpcraft/commands/release.py
@@ -0,0 +1,134 @@
+# Copyright 2023 Canonical Ltd. This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import re
+from argparse import ArgumentParser, Namespace
+from operator import attrgetter
+from urllib.parse import urlparse
+
+from craft_cli import BaseCommand, emit
+from launchpadlib.launchpad import Launchpad
+
+from lpcraft.errors import CommandError
+from lpcraft.git import get_current_branch, get_current_remote_url
+
+
+class ReleaseCommand(BaseCommand):
+ """Release a Launchpad build of a commit to a target archive."""
+
+ name = "release"
+ help_msg = __doc__.splitlines()[0]
+ overview = __doc__
+ common = True
+
+ def fill_parser(self, parser: ArgumentParser) -> None:
+ """Add arguments specific to this command."""
+ parser.add_argument(
+ "-l",
+ "--launchpad",
+ dest="launchpad_instance",
+ default="production",
+ help="Use this Launchpad instance.",
+ )
+ parser.add_argument(
+ "-n",
+ "--dry-run",
+ default=False,
+ action="store_true",
+ help="Just report what would be done.",
+ )
+ parser.add_argument(
+ "--repository",
+ help=(
+ "Git repository URL (defaults to the upstream repository for "
+ "the current branch, if on git.launchpad.net)"
+ ),
+ )
+ parser.add_argument(
+ "--commit",
+ help=(
+ "Git branch name, tag name, or commit ID (defaults to the "
+ "current branch)"
+ ),
+ )
+ parser.add_argument(
+ "archive", help="Target archive, e.g. ppa:OWNER/DISTRIBUTION/NAME"
+ )
+ parser.add_argument("suite", help="Target suite, e.g. focal")
+ parser.add_argument("channel", help="Target channel, e.g. edge")
+
+ def run(self, args: Namespace) -> int:
+ """Run the command."""
+ if args.repository is None:
+ current_remote_url = get_current_remote_url()
+ if current_remote_url is None:
+ raise CommandError(
+ "No --repository option was given, and the current branch "
+ "does not track a remote branch."
+ )
+ parsed_url = urlparse(current_remote_url)
+ # XXX cjwatson 2023-01-04: Ideally this would check for the git
+ # service corresponding to the --launchpad argument rather than
+ # hardcoding git.launchpad.net.
+ if parsed_url.netloc == "git.launchpad.net":
+ args.repository = parsed_url.path
+ else:
+ raise CommandError(
+ "No --repository option was given, and the current branch "
+ "does not track a remote branch on git.launchpad.net."
+ )
+ args.repository = args.repository.lstrip("/")
+ if args.commit is None:
+ args.commit = get_current_branch()
+ if args.commit is None:
+ raise CommandError(
+ "No --commit option was given, and there is no current "
+ "branch."
+ )
+
+ launchpad = Launchpad.login_with(
+ "lpcraft", args.launchpad_instance, version="devel"
+ )
+ repository = launchpad.git_repositories.getByPath(path=args.repository)
+ if repository is None:
+ raise CommandError(
+ f"Repository {args.repository} does not exist on Launchpad."
+ )
+ if re.match(r"^[0-9a-f]{40}$", args.commit) is None:
+ ref = repository.getRefByPath(path=args.commit)
+ if ref is None:
+ raise CommandError(
+ f"{args.repository} has no branch or tag named "
+ f"{args.commit}."
+ )
+ args.commit = ref.commit_sha1
+ reports = repository.getStatusReports(commit_sha1=args.commit)
+ builds = [
+ report.ci_build
+ for report in reports
+ if report.ci_build is not None
+ and report.ci_build.buildstate == "Successfully built"
+ and report.getArtifactURLs(artifact_type="Binary")
+ ]
+ if not builds:
+ raise CommandError(
+ f"{args.repository}:{args.commit} has no completed CI "
+ f"builds with attached files."
+ )
+ latest_build = sorted(builds, key=attrgetter("datebuilt"))[-1]
+ archive = launchpad.archives.getByReference(reference=args.archive)
+ description = (
+ f"build of {args.repository}:{args.commit} to "
+ f"{args.archive} {args.suite} {args.channel}"
+ )
+ if args.dry_run:
+ emit.message(f"Would release {description}.")
+ else:
+ archive.uploadCIBuild(
+ ci_build=latest_build,
+ to_series=args.suite,
+ to_pocket="Release",
+ to_channel=args.channel,
+ )
+ emit.message(f"Released {description}.")
+ return 0
diff --git a/lpcraft/commands/tests/filter-wadl.py b/lpcraft/commands/tests/filter-wadl.py
new file mode 100755
index 0000000..f1e1dae
--- /dev/null
+++ b/lpcraft/commands/tests/filter-wadl.py
@@ -0,0 +1,130 @@
+#! /usr/bin/python3
+# Copyright 2023 Canonical Ltd. This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+"""
+Make a filtered version of Launchpad's WADL description.
+
+Download Launchpad's WADL description and filter it to include only the
+parts we need.
+"""
+
+from pathlib import Path
+from xml.etree import ElementTree
+
+import requests
+
+wadl_namespace = "http://research.sun.com/wadl/2006/10"
+
+keep_collections = {
+ "archives": ["getByReference"],
+ "git_repositories": ["getByPath"],
+}
+
+keep_entries = {
+ "archive": ["uploadCIBuild"],
+ "ci_build": ["buildstate", "datebuilt", "get"],
+ "git_ref": ["commit_sha1", "get"],
+ "git_repository": ["getRefByPath", "getStatusReports"],
+ "revision_status_report": [
+ "ci_build_link",
+ "get",
+ "getArtifactURLs",
+ "page-resource-get",
+ ],
+}
+
+
+def download_wadl() -> str:
+ response = requests.get(
+ "https://api.launchpad.net/devel/",
+ headers={"Accept": "application/vnd.sun.wadl+xml"},
+ )
+ response.raise_for_status()
+ return response.text
+
+
+def reduce_wadl(wadl: str) -> ElementTree.Element:
+ ElementTree.register_namespace("wadl", wadl_namespace)
+ parsed = ElementTree.fromstring(wadl)
+
+ for resource_type in parsed.findall(
+ "wadl:resource_type", namespaces={"wadl": wadl_namespace}
+ ):
+ resource_type_name = resource_type.get("id")
+ if resource_type_name == "service-root":
+ continue
+ elif resource_type_name in keep_collections:
+ keep_methods = [
+ f"{resource_type_name}-{method}"
+ for method in keep_collections[resource_type_name]
+ ]
+ for method in resource_type.findall(
+ "wadl:method", namespaces={"wadl": wadl_namespace}
+ ):
+ if method.get("id") not in keep_methods:
+ resource_type.remove(method)
+ elif resource_type_name in keep_entries:
+ keep_methods = [
+ f"{resource_type_name}-{method}"
+ for method in keep_entries[resource_type_name]
+ ]
+ for method in resource_type.findall(
+ "wadl:method", namespaces={"wadl": wadl_namespace}
+ ):
+ if method.get("id") not in keep_methods:
+ resource_type.remove(method)
+ elif (
+ resource_type_name.endswith("-page-resource")
+ and resource_type_name.removesuffix("-page-resource")
+ in keep_entries
+ ):
+ continue
+ else:
+ parsed.remove(resource_type)
+
+ for representation in parsed.findall(
+ "wadl:representation", namespaces={"wadl": wadl_namespace}
+ ):
+ representation_name = representation.get("id").removesuffix("-full")
+ if representation_name == "service-root-json":
+ for collection_link_param in list(representation):
+ collection_name = collection_link_param.get("name")
+ if (
+ collection_name.removesuffix("_collection_link")
+ not in keep_collections
+ ):
+ representation.remove(collection_link_param)
+ elif representation_name in keep_entries:
+ for param in representation.findall(
+ "wadl:param", namespaces={"wadl": wadl_namespace}
+ ):
+ if param.get("name") in {
+ "http_etag",
+ "resource_type_link",
+ "self_link",
+ "web_link",
+ }:
+ continue
+ elif (
+ param.get("name") not in keep_entries[representation_name]
+ ):
+ representation.remove(param)
+ elif (
+ representation_name.endswith("-page")
+ and representation_name.removesuffix("-page") in keep_entries
+ ):
+ continue
+ else:
+ parsed.remove(representation)
+
+ return parsed
+
+
+def write_wadl(parsed: ElementTree.Element, path: Path) -> None:
+ ElementTree.ElementTree(parsed).write(path, xml_declaration=True)
+
+
+write_wadl(
+ reduce_wadl(download_wadl()), Path(__file__).parent / "launchpad-wadl.xml"
+)
diff --git a/lpcraft/commands/tests/launchpad-wadl.xml b/lpcraft/commands/tests/launchpad-wadl.xml
new file mode 100644
index 0000000..145b807
--- /dev/null
+++ b/lpcraft/commands/tests/launchpad-wadl.xml
@@ -0,0 +1,680 @@
+<?xml version='1.0' encoding='us-ascii'?>
+<wadl:application xmlns:html="http://www.w3.org/1999/xhtml" xmlns:wadl="http://research.sun.com/wadl/2006/10" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://research.sun.com/wadl/2006/10/wadl.xsd">
+
+ <wadl:doc title="About this service">The Launchpad web service allows automated
+ clients to access most of the functionality available on the
+ Launchpad web site. For help getting started, see
+ <html:a href="https://help.launchpad.net/API/">the help wiki.</html:a></wadl:doc>
+
+ <wadl:doc title="About version devel">This version of the web service reflects the most
+ recent changes made. It may abruptly change without
+ warning. Periodically, these changes are bundled up and given a
+ permanent version number.</wadl:doc>
+
+
+ <wadl:resources base="https://api.launchpad.net/devel/">
+ <wadl:resource path="" type="#service-root" />
+ </wadl:resources>
+
+
+ <wadl:resource_type id="service-root">
+ <wadl:doc>The root of the web service.</wadl:doc>
+ <wadl:method name="GET" id="service-root-get">
+ <wadl:response>
+ <wadl:representation href="#service-root-json" />
+ <wadl:representation mediaType="application/vnd.sun.wadl+xml" id="service-root-wadl" />
+ </wadl:response>
+ </wadl:method>
+ </wadl:resource_type>
+
+
+ <wadl:representation mediaType="application/json" id="service-root-json">
+
+ <wadl:param style="plain" name="archives_collection_link" path="$['archives_collection_link']">
+ <wadl:link resource_type="https://api.launchpad.net/devel/#archives" />
+ </wadl:param>
+
+
+ <wadl:param style="plain" name="git_repositories_collection_link" path="$['git_repositories_collection_link']">
+ <wadl:link resource_type="https://api.launchpad.net/devel/#git_repositories" />
+ </wadl:param>
+
+
+ </wadl:representation>
+
+
+
+
+
+ <wadl:resource_type id="archives">
+ <wadl:doc>
+Interface for ArchiveSet
+</wadl:doc>
+ <wadl:method id="archives-getByReference" name="GET">
+ <wadl:doc>
+Return the IArchive with the given archive reference.
+</wadl:doc>
+ <wadl:request>
+
+ <wadl:param style="query" name="ws.op" required="true" fixed="getByReference">
+ <wadl:doc>The name of the operation being invoked.</wadl:doc>
+ </wadl:param>
+ <wadl:param style="query" name="reference" required="true">
+ <wadl:doc>
+Archive reference string
+</wadl:doc>
+
+ </wadl:param>
+
+ </wadl:request>
+ <wadl:response>
+
+ <wadl:representation href="https://api.launchpad.net/devel/#archive-full" />
+ </wadl:response>
+ </wadl:method>
+ </wadl:resource_type>
+
+
+
+ <wadl:resource_type id="git_repositories">
+ <wadl:doc>
+Interface representing the set of Git repositories.
+</wadl:doc>
+ <wadl:method id="git_repositories-getByPath" name="GET">
+ <wadl:doc>
+<html:p>Find a repository by its path.</html:p>
+<html:p>Any of these forms may be used:</html:p>
+<html:pre class="rst-literal-block">
+Unique names:
+ ~OWNER/PROJECT/+git/NAME
+ ~OWNER/DISTRO/+source/SOURCE/+git/NAME
+ ~OWNER/+git/NAME
+Owner-target default aliases:
+ ~OWNER/PROJECT
+ ~OWNER/DISTRO/+source/SOURCE
+Official aliases:
+ PROJECT
+ DISTRO/+source/SOURCE
+</html:pre>
+<html:p>Return None if no match was found.</html:p>
+
+</wadl:doc>
+ <wadl:request>
+
+ <wadl:param style="query" name="ws.op" required="true" fixed="getByPath">
+ <wadl:doc>The name of the operation being invoked.</wadl:doc>
+ </wadl:param>
+ <wadl:param style="query" name="path" required="true">
+ <wadl:doc>
+Repository path
+</wadl:doc>
+
+ </wadl:param>
+
+ </wadl:request>
+ <wadl:response>
+
+ <wadl:representation href="https://api.launchpad.net/devel/#git_repository-full" />
+ </wadl:response>
+ </wadl:method>
+ </wadl:resource_type>
+
+
+
+ <wadl:resource_type id="archive">
+ <wadl:doc>
+Main Archive interface.
+</wadl:doc>
+ <wadl:method id="archive-uploadCIBuild" name="POST">
+ <wadl:doc>
+Upload the output of a CI build to this archive.
+</wadl:doc>
+ <wadl:request>
+ <wadl:representation mediaType="application/x-www-form-urlencoded">
+ <wadl:param style="query" name="ws.op" required="true" fixed="uploadCIBuild" />
+ <wadl:param style="query" name="to_pocket" required="true">
+ <wadl:doc>
+Target pocket name
+</wadl:doc>
+
+ </wadl:param>
+ <wadl:param style="query" name="ci_build" required="true">
+
+ <wadl:link resource_type="https://api.launchpad.net/devel/#ci_build" />
+ </wadl:param>
+ <wadl:param style="query" name="to_channel" required="false">
+ <wadl:doc>
+Target channel
+</wadl:doc>
+
+ </wadl:param>
+ <wadl:param style="query" name="to_series" required="true">
+ <wadl:doc>
+Target distroseries name
+</wadl:doc>
+
+ </wadl:param>
+ </wadl:representation>
+ </wadl:request>
+
+ </wadl:method>
+ </wadl:resource_type>
+
+
+ <wadl:representation mediaType="application/json" id="archive-full">
+ <wadl:param style="plain" name="self_link" path="$['self_link']">
+ <wadl:doc>The canonical link to this resource.</wadl:doc>
+ <wadl:link resource_type="https://api.launchpad.net/devel/#archive" />
+ </wadl:param>
+ <wadl:param style="plain" name="web_link" path="$['web_link']">
+ <wadl:doc>
+ The canonical human-addressable web link to this resource.
+ </wadl:doc>
+ <wadl:link />
+ </wadl:param>
+ <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+ <wadl:doc>
+ The link to the WADL description of this resource.
+ </wadl:doc>
+ <wadl:link />
+ </wadl:param>
+ <wadl:param style="plain" name="http_etag" path="$['http_etag']">
+ <wadl:doc>
+ The value of the HTTP ETag for this resource.
+ </wadl:doc>
+ </wadl:param>
+ </wadl:representation>
+
+ <wadl:resource_type id="archive-page-resource">
+ <wadl:method name="GET" id="archive-page-resource-get">
+ <wadl:response>
+ <wadl:representation href="#archive-page" />
+ </wadl:response>
+ </wadl:method>
+ </wadl:resource_type>
+
+ <wadl:representation mediaType="application/json" id="archive-page">
+
+ <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+ <wadl:link />
+ </wadl:param>
+
+
+
+ <wadl:param style="plain" name="total_size" path="$['total_size']" required="false" />
+
+ <wadl:param style="plain" name="total_size_link" path="$['total_size_link']" required="false">
+ <wadl:link resource_type="#ScalarValue" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="start" path="$['start']" required="true" />
+
+ <wadl:param style="plain" name="next_collection_link" path="$['next_collection_link']">
+ <wadl:link resource_type="#archive-page-resource" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="prev_collection_link" path="$['prev_collection_link']">
+ <wadl:link resource_type="#archive-page-resource" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="entries" path="$['entries']" required="true" />
+
+ <wadl:param style="plain" name="entry_links" path="$['entries'][*]['self_link']">
+ <wadl:link resource_type="https://api.launchpad.net/devel/#archive" />
+ </wadl:param>
+ </wadl:representation>
+
+
+
+ <wadl:resource_type id="ci_build">
+ <wadl:doc>
+A build record for a pipeline of CI jobs.
+</wadl:doc>
+ <wadl:method name="GET" id="ci_build-get">
+ <wadl:response>
+ <wadl:representation href="https://api.launchpad.net/devel/#ci_build-full" />
+ <wadl:representation mediaType="application/xhtml+xml" id="ci_build-xhtml" />
+ <wadl:representation mediaType="application/vnd.sun.wadl+xml" id="ci_build-wadl" />
+ </wadl:response>
+ </wadl:method>
+
+ </wadl:resource_type>
+
+
+ <wadl:representation mediaType="application/json" id="ci_build-full">
+ <wadl:param style="plain" name="self_link" path="$['self_link']">
+ <wadl:doc>The canonical link to this resource.</wadl:doc>
+ <wadl:link resource_type="https://api.launchpad.net/devel/#ci_build" />
+ </wadl:param>
+ <wadl:param style="plain" name="web_link" path="$['web_link']">
+ <wadl:doc>
+ The canonical human-addressable web link to this resource.
+ </wadl:doc>
+ <wadl:link />
+ </wadl:param>
+ <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+ <wadl:doc>
+ The link to the WADL description of this resource.
+ </wadl:doc>
+ <wadl:link />
+ </wadl:param>
+ <wadl:param style="plain" name="http_etag" path="$['http_etag']">
+ <wadl:doc>
+ The value of the HTTP ETag for this resource.
+ </wadl:doc>
+ </wadl:param>
+ <wadl:param style="plain" required="true" name="buildstate" path="$['buildstate']">
+ <wadl:doc>
+<html:p>Status</html:p>
+<html:p>The current status of the job.</html:p>
+
+</wadl:doc>
+
+ <wadl:option value="Needs building" />
+ <wadl:option value="Successfully built" />
+ <wadl:option value="Failed to build" />
+ <wadl:option value="Dependency wait" />
+ <wadl:option value="Chroot problem" />
+ <wadl:option value="Build for superseded Source" />
+ <wadl:option value="Currently building" />
+ <wadl:option value="Failed to upload" />
+ <wadl:option value="Uploading build" />
+ <wadl:option value="Cancelling build" />
+ <wadl:option value="Cancelled build" />
+ </wadl:param>
+ <wadl:param style="plain" required="true" name="datebuilt" path="$['datebuilt']" type="xsd:dateTime">
+ <wadl:doc>
+<html:p>Date finished</html:p>
+<html:p>The timestamp when the build farm job was finished.</html:p>
+
+</wadl:doc>
+
+ </wadl:param>
+ </wadl:representation>
+
+ <wadl:resource_type id="ci_build-page-resource">
+ <wadl:method name="GET" id="ci_build-page-resource-get">
+ <wadl:response>
+ <wadl:representation href="#ci_build-page" />
+ </wadl:response>
+ </wadl:method>
+ </wadl:resource_type>
+
+ <wadl:representation mediaType="application/json" id="ci_build-page">
+
+ <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+ <wadl:link />
+ </wadl:param>
+
+
+
+ <wadl:param style="plain" name="total_size" path="$['total_size']" required="false" />
+
+ <wadl:param style="plain" name="total_size_link" path="$['total_size_link']" required="false">
+ <wadl:link resource_type="#ScalarValue" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="start" path="$['start']" required="true" />
+
+ <wadl:param style="plain" name="next_collection_link" path="$['next_collection_link']">
+ <wadl:link resource_type="#ci_build-page-resource" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="prev_collection_link" path="$['prev_collection_link']">
+ <wadl:link resource_type="#ci_build-page-resource" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="entries" path="$['entries']" required="true" />
+
+ <wadl:param style="plain" name="entry_links" path="$['entries'][*]['self_link']">
+ <wadl:link resource_type="https://api.launchpad.net/devel/#ci_build" />
+ </wadl:param>
+ </wadl:representation>
+
+
+
+ <wadl:resource_type id="git_ref">
+ <wadl:doc>
+A reference in a Git repository.
+</wadl:doc>
+ <wadl:method name="GET" id="git_ref-get">
+ <wadl:response>
+ <wadl:representation href="https://api.launchpad.net/devel/#git_ref-full" />
+ <wadl:representation mediaType="application/xhtml+xml" id="git_ref-xhtml" />
+ <wadl:representation mediaType="application/vnd.sun.wadl+xml" id="git_ref-wadl" />
+ </wadl:response>
+ </wadl:method>
+
+ </wadl:resource_type>
+
+
+ <wadl:representation mediaType="application/json" id="git_ref-full">
+ <wadl:param style="plain" name="self_link" path="$['self_link']">
+ <wadl:doc>The canonical link to this resource.</wadl:doc>
+ <wadl:link resource_type="https://api.launchpad.net/devel/#git_ref" />
+ </wadl:param>
+ <wadl:param style="plain" name="web_link" path="$['web_link']">
+ <wadl:doc>
+ The canonical human-addressable web link to this resource.
+ </wadl:doc>
+ <wadl:link />
+ </wadl:param>
+ <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+ <wadl:doc>
+ The link to the WADL description of this resource.
+ </wadl:doc>
+ <wadl:link />
+ </wadl:param>
+ <wadl:param style="plain" name="http_etag" path="$['http_etag']">
+ <wadl:doc>
+ The value of the HTTP ETag for this resource.
+ </wadl:doc>
+ </wadl:param>
+ <wadl:param style="plain" required="true" name="commit_sha1" path="$['commit_sha1']">
+ <wadl:doc>
+<html:p>Commit SHA-1</html:p>
+<html:p>The full SHA-1 object name of the commit object referenced by this reference.</html:p>
+
+</wadl:doc>
+
+ </wadl:param>
+ </wadl:representation>
+
+ <wadl:resource_type id="git_ref-page-resource">
+ <wadl:method name="GET" id="git_ref-page-resource-get">
+ <wadl:response>
+ <wadl:representation href="#git_ref-page" />
+ </wadl:response>
+ </wadl:method>
+ </wadl:resource_type>
+
+ <wadl:representation mediaType="application/json" id="git_ref-page">
+
+ <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+ <wadl:link />
+ </wadl:param>
+
+
+
+ <wadl:param style="plain" name="total_size" path="$['total_size']" required="false" />
+
+ <wadl:param style="plain" name="total_size_link" path="$['total_size_link']" required="false">
+ <wadl:link resource_type="#ScalarValue" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="start" path="$['start']" required="true" />
+
+ <wadl:param style="plain" name="next_collection_link" path="$['next_collection_link']">
+ <wadl:link resource_type="#git_ref-page-resource" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="prev_collection_link" path="$['prev_collection_link']">
+ <wadl:link resource_type="#git_ref-page-resource" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="entries" path="$['entries']" required="true" />
+
+ <wadl:param style="plain" name="entry_links" path="$['entries'][*]['self_link']">
+ <wadl:link resource_type="https://api.launchpad.net/devel/#git_ref" />
+ </wadl:param>
+ </wadl:representation>
+
+
+
+ <wadl:resource_type id="git_repository">
+ <wadl:doc>
+A Git repository.
+</wadl:doc>
+ <wadl:method id="git_repository-getRefByPath" name="GET">
+ <wadl:doc>
+<html:p>Look up a single reference in this repository by path.</html:p>
+<html:table class="rst-docutils field-list" frame="void" rules="none">
+<html:col class="field-name" />
+<html:col class="field-body" />
+<html:tbody valign="top">
+<html:tr class="rst-field"><html:th class="rst-field-name">param path:</html:th><html:td class="rst-field-body">A string to look up as a path.</html:td>
+</html:tr>
+<html:tr class="rst-field"><html:th class="rst-field-name">return:</html:th><html:td class="rst-field-body">An IGitRef, or None.</html:td>
+</html:tr>
+</html:tbody>
+</html:table>
+
+</wadl:doc>
+ <wadl:request>
+
+ <wadl:param style="query" name="ws.op" required="true" fixed="getRefByPath" />
+ <wadl:param style="query" name="path" required="true">
+ <wadl:doc>
+A string to look up as a path.
+</wadl:doc>
+
+ </wadl:param>
+
+ </wadl:request>
+ <wadl:response>
+
+ <wadl:representation href="https://api.launchpad.net/devel/#git_ref-full" />
+ </wadl:response>
+ </wadl:method>
+ <wadl:method id="git_repository-getStatusReports" name="GET">
+ <wadl:doc>
+<html:p>Retrieves the list of reports that exist for a commit.</html:p>
+<html:blockquote>
+<html:table class="rst-docutils field-list" frame="void" rules="none">
+<html:col class="field-name" />
+<html:col class="field-body" />
+<html:tbody valign="top">
+<html:tr class="rst-field"><html:th class="rst-field-name" colspan="2">param commit_sha1:</html:th></html:tr>
+<html:tr class="rst-field"><html:td>\ </html:td><html:td class="rst-field-body">The commit sha1 for the report.</html:td>
+</html:tr>
+</html:tbody>
+</html:table>
+</html:blockquote>
+<html:p>Scopes: <html:tt class="rst-docutils literal">repository:build_status</html:tt></html:p>
+
+</wadl:doc>
+ <wadl:request>
+
+ <wadl:param style="query" name="ws.op" required="true" fixed="getStatusReports" />
+ <wadl:param style="query" name="commit_sha1" required="true">
+ <wadl:doc>
+The Git commit for which this report is built.
+</wadl:doc>
+
+ </wadl:param>
+
+ </wadl:request>
+ <wadl:response>
+
+ <wadl:representation href="https://api.launchpad.net/devel/#revision_status_report-page" />
+ </wadl:response>
+ </wadl:method>
+ </wadl:resource_type>
+
+
+ <wadl:representation mediaType="application/json" id="git_repository-full">
+ <wadl:param style="plain" name="self_link" path="$['self_link']">
+ <wadl:doc>The canonical link to this resource.</wadl:doc>
+ <wadl:link resource_type="https://api.launchpad.net/devel/#git_repository" />
+ </wadl:param>
+ <wadl:param style="plain" name="web_link" path="$['web_link']">
+ <wadl:doc>
+ The canonical human-addressable web link to this resource.
+ </wadl:doc>
+ <wadl:link />
+ </wadl:param>
+ <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+ <wadl:doc>
+ The link to the WADL description of this resource.
+ </wadl:doc>
+ <wadl:link />
+ </wadl:param>
+ <wadl:param style="plain" name="http_etag" path="$['http_etag']">
+ <wadl:doc>
+ The value of the HTTP ETag for this resource.
+ </wadl:doc>
+ </wadl:param>
+ </wadl:representation>
+
+ <wadl:resource_type id="git_repository-page-resource">
+ <wadl:method name="GET" id="git_repository-page-resource-get">
+ <wadl:response>
+ <wadl:representation href="#git_repository-page" />
+ </wadl:response>
+ </wadl:method>
+ </wadl:resource_type>
+
+ <wadl:representation mediaType="application/json" id="git_repository-page">
+
+ <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+ <wadl:link />
+ </wadl:param>
+
+
+
+ <wadl:param style="plain" name="total_size" path="$['total_size']" required="false" />
+
+ <wadl:param style="plain" name="total_size_link" path="$['total_size_link']" required="false">
+ <wadl:link resource_type="#ScalarValue" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="start" path="$['start']" required="true" />
+
+ <wadl:param style="plain" name="next_collection_link" path="$['next_collection_link']">
+ <wadl:link resource_type="#git_repository-page-resource" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="prev_collection_link" path="$['prev_collection_link']">
+ <wadl:link resource_type="#git_repository-page-resource" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="entries" path="$['entries']" required="true" />
+
+ <wadl:param style="plain" name="entry_links" path="$['entries'][*]['self_link']">
+ <wadl:link resource_type="https://api.launchpad.net/devel/#git_repository" />
+ </wadl:param>
+ </wadl:representation>
+
+
+
+ <wadl:resource_type id="revision_status_report">
+ <wadl:doc>
+An revision status report for a Git commit.
+</wadl:doc>
+ <wadl:method name="GET" id="revision_status_report-get">
+ <wadl:response>
+ <wadl:representation href="https://api.launchpad.net/devel/#revision_status_report-full" />
+ <wadl:representation mediaType="application/xhtml+xml" id="revision_status_report-xhtml" />
+ <wadl:representation mediaType="application/vnd.sun.wadl+xml" id="revision_status_report-wadl" />
+ </wadl:response>
+ </wadl:method>
+
+ <wadl:method id="revision_status_report-getArtifactURLs" name="GET">
+ <wadl:doc>
+<html:p>Retrieves the list of URLs for artifacts that exist for this report.</html:p>
+<html:blockquote>
+<html:table class="rst-docutils field-list" frame="void" rules="none">
+<html:col class="field-name" />
+<html:col class="field-body" />
+<html:tbody valign="top">
+<html:tr class="rst-field"><html:th class="rst-field-name" colspan="2">param artifact_type:</html:th></html:tr>
+<html:tr class="rst-field"><html:td>\ </html:td><html:td class="rst-field-body">The type of artifact for the report.</html:td>
+</html:tr>
+</html:tbody>
+</html:table>
+</html:blockquote>
+<html:p>Scopes: <html:tt class="rst-docutils literal">repository:build_status</html:tt></html:p>
+
+</wadl:doc>
+ <wadl:request>
+
+ <wadl:param style="query" name="ws.op" required="true" fixed="getArtifactURLs" />
+ <wadl:param style="query" name="artifact_type" required="false">
+
+
+ <wadl:option value="Log" />
+ <wadl:option value="Binary" />
+ </wadl:param>
+
+ </wadl:request>
+
+ </wadl:method>
+ </wadl:resource_type>
+
+
+ <wadl:representation mediaType="application/json" id="revision_status_report-full">
+ <wadl:param style="plain" name="self_link" path="$['self_link']">
+ <wadl:doc>The canonical link to this resource.</wadl:doc>
+ <wadl:link resource_type="https://api.launchpad.net/devel/#revision_status_report" />
+ </wadl:param>
+ <wadl:param style="plain" name="web_link" path="$['web_link']">
+ <wadl:doc>
+ The canonical human-addressable web link to this resource.
+ </wadl:doc>
+ <wadl:link />
+ </wadl:param>
+ <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+ <wadl:doc>
+ The link to the WADL description of this resource.
+ </wadl:doc>
+ <wadl:link />
+ </wadl:param>
+ <wadl:param style="plain" name="http_etag" path="$['http_etag']">
+ <wadl:doc>
+ The value of the HTTP ETag for this resource.
+ </wadl:doc>
+ </wadl:param>
+ <wadl:param style="plain" required="true" name="ci_build_link" path="$['ci_build_link']">
+ <wadl:doc>
+The CI build that produced this report.
+</wadl:doc>
+ <wadl:link resource_type="https://api.launchpad.net/devel/#ci_build" />
+ </wadl:param>
+ </wadl:representation>
+
+ <wadl:resource_type id="revision_status_report-page-resource">
+ <wadl:method name="GET" id="revision_status_report-page-resource-get">
+ <wadl:response>
+ <wadl:representation href="#revision_status_report-page" />
+ </wadl:response>
+ </wadl:method>
+ </wadl:resource_type>
+
+ <wadl:representation mediaType="application/json" id="revision_status_report-page">
+
+ <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+ <wadl:link />
+ </wadl:param>
+
+
+
+ <wadl:param style="plain" name="total_size" path="$['total_size']" required="false" />
+
+ <wadl:param style="plain" name="total_size_link" path="$['total_size_link']" required="false">
+ <wadl:link resource_type="#ScalarValue" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="start" path="$['start']" required="true" />
+
+ <wadl:param style="plain" name="next_collection_link" path="$['next_collection_link']">
+ <wadl:link resource_type="#revision_status_report-page-resource" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="prev_collection_link" path="$['prev_collection_link']">
+ <wadl:link resource_type="#revision_status_report-page-resource" />
+ </wadl:param>
+
+ <wadl:param style="plain" name="entries" path="$['entries']" required="true" />
+
+ <wadl:param style="plain" name="entry_links" path="$['entries'][*]['self_link']">
+ <wadl:link resource_type="https://api.launchpad.net/devel/#revision_status_report" />
+ </wadl:param>
+ </wadl:representation>
+
+
+
+ <xs:simpleType name="binary">
+ <xs:list itemType="byte" />
+ </xs:simpleType>
+
+</wadl:application>
\ No newline at end of file
diff --git a/lpcraft/commands/tests/test_release.py b/lpcraft/commands/tests/test_release.py
new file mode 100644
index 0000000..c854f02
--- /dev/null
+++ b/lpcraft/commands/tests/test_release.py
@@ -0,0 +1,477 @@
+# Copyright 2023 Canonical Ltd. This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import io
+from datetime import datetime
+from pathlib import Path
+
+from fixtures import MockPatchObject
+from launchpadlib.launchpad import Launchpad
+from launchpadlib.testing.launchpad import FakeLaunchpad
+from systemfixtures import FakeProcesses
+from testtools.matchers import Equals, MatchesListwise, MatchesStructure
+from wadllib.application import Application
+
+from lpcraft.commands.tests import CommandBaseTestCase
+from lpcraft.errors import CommandError
+
+
+class TestRelease(CommandBaseTestCase):
+ def setUp(self):
+ super().setUp()
+ self.uploads = []
+
+ def set_up_local_branch(
+ self, branch_name: str, remote_name: str, url: str
+ ):
+ def fake_git(args):
+ if args["args"][1] == "branch":
+ return {"stdout": io.StringIO(f"{branch_name}\n")}
+ elif args["args"][1] == "config":
+ return {"stdout": io.StringIO(f"{remote_name}\n")}
+ elif args["args"][1] == "remote":
+ return {"stdout": io.StringIO(f"{url}\n")}
+ else: # pragma: no cover
+ return {"returncode": 1}
+
+ processes_fixture = self.useFixture(FakeProcesses())
+ processes_fixture.add(fake_git, name="git")
+ return processes_fixture
+
+ def make_fake_launchpad(self) -> FakeLaunchpad:
+ lp = FakeLaunchpad(
+ application=Application(
+ "https://api.launchpad.net/devel/",
+ (Path(__file__).parent / "launchpad-wadl.xml").read_bytes(),
+ )
+ )
+ self.useFixture(
+ MockPatchObject(
+ Launchpad, "login_with", lambda *args, **kwargs: lp
+ )
+ )
+ return lp
+
+ def fake_upload(self, ci_build, to_series, to_pocket, to_channel):
+ self.uploads.append((ci_build, to_series, to_pocket, to_channel))
+
+ def test_no_repository_argument_and_no_remote_branch(self):
+ processes_fixture = self.useFixture(FakeProcesses())
+ processes_fixture.add(
+ lambda _: {"stdout": io.StringIO("\n")}, name="git"
+ )
+
+ result = self.run_command(
+ "release", "ppa:owner/ubuntu/name", "focal", "edge"
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=1,
+ errors=[
+ CommandError(
+ "No --repository option was given, and the current "
+ "branch does not track a remote branch."
+ )
+ ],
+ ),
+ )
+
+ def test_no_repository_argument_and_remote_branch_on_bad_host(self):
+ self.set_up_local_branch(
+ "feature", "origin", "git+ssh://git.example.com/"
+ )
+
+ result = self.run_command(
+ "release", "ppa:owner/ubuntu/name", "focal", "edge"
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=1,
+ errors=[
+ CommandError(
+ "No --repository option was given, and the current "
+ "branch does not track a remote branch on "
+ "git.launchpad.net."
+ )
+ ],
+ ),
+ )
+
+ def test_no_commit_argument_and_no_current_branch(self):
+ processes_fixture = self.useFixture(FakeProcesses())
+ processes_fixture.add(
+ lambda _: {"stdout": io.StringIO("\n")}, name="git"
+ )
+
+ result = self.run_command(
+ "release",
+ "--repository",
+ "example",
+ "ppa:owner/ubuntu/name",
+ "focal",
+ "edge",
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=1,
+ errors=[
+ CommandError(
+ "No --commit option was given, and there is no "
+ "current branch."
+ )
+ ],
+ ),
+ )
+
+ def test_repository_does_not_exist(self):
+ lp = self.make_fake_launchpad()
+ lp.git_repositories = {"getByPath": lambda path: None}
+
+ result = self.run_command(
+ "release",
+ "--repository",
+ "example",
+ "--commit",
+ "missing",
+ "ppa:owner/ubuntu/name",
+ "focal",
+ "edge",
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=1,
+ errors=[
+ CommandError(
+ "Repository example does not exist on Launchpad."
+ )
+ ],
+ ),
+ )
+
+ def test_no_branch_or_tag(self):
+ lp = self.make_fake_launchpad()
+ lp.git_repositories = {
+ "getByPath": lambda path: {"getRefByPath": lambda path: None}
+ }
+
+ result = self.run_command(
+ "release",
+ "--repository",
+ "example",
+ "--commit",
+ "missing",
+ "ppa:owner/ubuntu/name",
+ "focal",
+ "edge",
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=1,
+ errors=[
+ CommandError("example has no branch or tag named missing.")
+ ],
+ ),
+ )
+
+ def test_no_completed_ci_builds(self):
+ lp = self.make_fake_launchpad()
+ commit_sha1 = "1" * 40
+ lp.git_repositories = {
+ "getByPath": lambda path: {
+ "getRefByPath": lambda path: {"commit_sha1": commit_sha1},
+ "getStatusReports": lambda commit_sha1: {
+ "entries": [{"ci_build": {"buildstate": "Needs building"}}]
+ },
+ }
+ }
+
+ result = self.run_command(
+ "release",
+ "--repository",
+ "example",
+ "--commit",
+ "branch",
+ "ppa:owner/ubuntu/name",
+ "focal",
+ "edge",
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=1,
+ errors=[
+ CommandError(
+ f"example:{commit_sha1} has no completed CI builds "
+ f"with attached files."
+ )
+ ],
+ ),
+ )
+
+ def test_no_ci_builds_with_artifacts(self):
+ lp = self.make_fake_launchpad()
+ commit_sha1 = "1" * 40
+ lp.git_repositories = {
+ "getByPath": lambda path: {
+ "getRefByPath": lambda path: {"commit_sha1": commit_sha1},
+ "getStatusReports": lambda commit_sha1: {
+ "entries": [
+ {
+ "ci_build": {"buildstate": "Successfully built"},
+ "getArtifactURLs": lambda artifact_type: [],
+ }
+ ]
+ },
+ }
+ }
+
+ result = self.run_command(
+ "release",
+ "--repository",
+ "example",
+ "--commit",
+ "branch",
+ "ppa:owner/ubuntu/name",
+ "focal",
+ "edge",
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=1,
+ errors=[
+ CommandError(
+ f"example:{commit_sha1} has no completed CI builds "
+ f"with attached files."
+ )
+ ],
+ ),
+ )
+
+ def test_dry_run(self):
+ lp = self.make_fake_launchpad()
+ commit_sha1 = "1" * 40
+ lp.git_repositories = {
+ "getByPath": lambda path: {
+ "getRefByPath": lambda path: {"commit_sha1": commit_sha1},
+ "getStatusReports": lambda commit_sha1: {
+ "entries": [
+ {
+ "ci_build": {
+ "buildstate": "Successfully built",
+ "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
+ },
+ "getArtifactURLs": lambda artifact_type: ["url"],
+ }
+ ]
+ },
+ }
+ }
+ lp.archives = {
+ "getByReference": lambda reference: {
+ "uploadCIBuild": self.fake_upload
+ }
+ }
+
+ result = self.run_command(
+ "release",
+ "--dry-run",
+ "--repository",
+ "example",
+ "--commit",
+ "branch",
+ "ppa:owner/ubuntu/name",
+ "focal",
+ "edge",
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=0,
+ messages=[
+ f"Would release build of example:{commit_sha1} to "
+ f"ppa:owner/ubuntu/name focal edge."
+ ],
+ ),
+ )
+ self.assertEqual([], self.uploads)
+
+ def test_release(self):
+ lp = self.make_fake_launchpad()
+ commit_sha1 = "1" * 40
+ lp.git_repositories = {
+ "getByPath": lambda path: {
+ "getRefByPath": lambda path: {"commit_sha1": commit_sha1},
+ "getStatusReports": lambda commit_sha1: {
+ "entries": [
+ {
+ "ci_build": {
+ "buildstate": "Successfully built",
+ "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
+ },
+ "getArtifactURLs": lambda artifact_type: ["url"],
+ },
+ {
+ "ci_build": {
+ "buildstate": "Successfully built",
+ "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
+ },
+ "getArtifactURLs": lambda artifact_type: ["url"],
+ },
+ ]
+ },
+ }
+ }
+ lp.archives = {
+ "getByReference": lambda reference: {
+ "uploadCIBuild": self.fake_upload
+ }
+ }
+
+ result = self.run_command(
+ "release",
+ "--repository",
+ "example",
+ "--commit",
+ "branch",
+ "ppa:owner/ubuntu/name",
+ "focal",
+ "edge",
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=0,
+ messages=[
+ f"Released build of example:{commit_sha1} to "
+ f"ppa:owner/ubuntu/name focal edge."
+ ],
+ ),
+ )
+ [upload] = self.uploads
+ self.assertThat(
+ upload,
+ MatchesListwise(
+ [
+ MatchesStructure(
+ datebuilt=Equals(datetime(2022, 1, 1, 12, 0, 0))
+ ),
+ Equals("focal"),
+ Equals("Release"),
+ Equals("edge"),
+ ]
+ ),
+ )
+
+ def test_release_with_commit_id(self):
+ lp = self.make_fake_launchpad()
+ commit_sha1 = "1" * 40
+ lp.git_repositories = {
+ "getByPath": lambda path: {
+ "getStatusReports": lambda commit_sha1: {
+ "entries": [
+ {
+ "ci_build": {
+ "buildstate": "Successfully built",
+ "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
+ },
+ "getArtifactURLs": lambda artifact_type: ["url"],
+ },
+ {
+ "ci_build": {
+ "buildstate": "Successfully built",
+ "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
+ },
+ "getArtifactURLs": lambda artifact_type: ["url"],
+ },
+ ]
+ },
+ }
+ }
+ lp.archives = {
+ "getByReference": lambda reference: {
+ "uploadCIBuild": self.fake_upload
+ }
+ }
+
+ result = self.run_command(
+ "release",
+ "--repository",
+ "example",
+ "--commit",
+ commit_sha1,
+ "ppa:owner/ubuntu/name",
+ "focal",
+ "edge",
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=0,
+ messages=[
+ f"Released build of example:{commit_sha1} to "
+ f"ppa:owner/ubuntu/name focal edge."
+ ],
+ ),
+ )
+
+ def test_release_from_current_branch(self):
+ self.set_up_local_branch(
+ "feature", "origin", "git+ssh://git.launchpad.net/example"
+ )
+ lp = self.make_fake_launchpad()
+ commit_sha1 = "1" * 40
+ lp.git_repositories = {
+ "getByPath": lambda path: {
+ "getRefByPath": lambda path: {"commit_sha1": commit_sha1},
+ "getStatusReports": lambda commit_sha1: {
+ "entries": [
+ {
+ "ci_build": {
+ "buildstate": "Successfully built",
+ "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
+ },
+ "getArtifactURLs": lambda artifact_type: ["url"],
+ }
+ ]
+ },
+ }
+ }
+ lp.archives = {
+ "getByReference": lambda reference: {
+ "uploadCIBuild": self.fake_upload
+ }
+ }
+
+ result = self.run_command(
+ "release",
+ "ppa:owner/ubuntu/name",
+ "focal",
+ "edge",
+ )
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=0,
+ messages=[
+ f"Released build of example:{commit_sha1} to "
+ f"ppa:owner/ubuntu/name focal edge."
+ ],
+ ),
+ )
diff --git a/lpcraft/git.py b/lpcraft/git.py
new file mode 100644
index 0000000..21f1f07
--- /dev/null
+++ b/lpcraft/git.py
@@ -0,0 +1,50 @@
+# Copyright 2023 Canonical Ltd. This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+ "get_current_branch",
+ "get_current_remote_url",
+]
+
+import subprocess
+from typing import Optional
+
+
+def get_current_branch() -> Optional[str]:
+ """Return the current Git branch name.
+
+ If there is no current branch (e.g. after `git checkout --detach`), then
+ return None.
+ """
+ current_branch = subprocess.run(
+ ["git", "branch", "--show-current"],
+ capture_output=True,
+ check=True,
+ text=True,
+ ).stdout.rstrip("\n")
+ if current_branch:
+ return current_branch
+
+
+def get_current_remote_url() -> Optional[str]:
+ """Return the remote URL for the current Git branch.
+
+ If there is no current branch, or if the current branch is not a
+ remote-tracking branch, then return None.
+ """
+ current_branch = get_current_branch()
+ if current_branch is None:
+ return None
+ current_remote = subprocess.run(
+ ["git", "config", f"branch.{current_branch}.remote"],
+ capture_output=True,
+ check=True,
+ text=True,
+ ).stdout.rstrip("\n")
+ if current_remote:
+ return subprocess.run(
+ ["git", "remote", "get-url", current_remote],
+ capture_output=True,
+ check=True,
+ text=True,
+ ).stdout.rstrip("\n")
diff --git a/lpcraft/main.py b/lpcraft/main.py
index fd08566..6b27e37 100644
--- a/lpcraft/main.py
+++ b/lpcraft/main.py
@@ -20,6 +20,7 @@ from craft_cli import (
from lpcraft._version import version_description as lpcraft_version
from lpcraft.commands.clean import CleanCommand
+from lpcraft.commands.release import ReleaseCommand
from lpcraft.commands.run import RunCommand, RunOneCommand
from lpcraft.commands.version import VersionCommand
@@ -43,6 +44,9 @@ _basic_commands = [
RunOneCommand,
VersionCommand,
]
+_launchpad_commands = [
+ ReleaseCommand,
+]
def main(argv: Optional[List[str]] = None) -> int:
@@ -51,7 +55,10 @@ def main(argv: Optional[List[str]] = None) -> int:
argv = sys.argv[1:]
emit.init(EmitterMode.BRIEF, "lpcraft", f"Starting {lpcraft_version}")
- command_groups = [CommandGroup("Basic", _basic_commands)]
+ command_groups = [
+ CommandGroup("Basic", _basic_commands),
+ CommandGroup("Launchpad", _launchpad_commands),
+ ]
summary = "Run Launchpad CI jobs."
extra_global_args = [
GlobalArgument(
diff --git a/lpcraft/tests/test_git.py b/lpcraft/tests/test_git.py
new file mode 100644
index 0000000..f2b47e7
--- /dev/null
+++ b/lpcraft/tests/test_git.py
@@ -0,0 +1,99 @@
+# Copyright 2023 Canonical Ltd. This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import io
+
+from systemfixtures import FakeProcesses
+from testtools import TestCase
+
+from lpcraft.git import get_current_branch, get_current_remote_url
+
+
+class TestGetCurrentBranch(TestCase):
+ def test_has_no_current_branch(self):
+ processes_fixture = self.useFixture(FakeProcesses())
+ processes_fixture.add(
+ lambda _: {"stdout": io.StringIO("\n")}, name="git"
+ )
+
+ self.assertIsNone(get_current_branch())
+
+ self.assertEqual(
+ [["git", "branch", "--show-current"]],
+ [proc._args["args"] for proc in processes_fixture.procs],
+ )
+
+ def test_has_current_branch(self):
+ processes_fixture = self.useFixture(FakeProcesses())
+ processes_fixture.add(
+ lambda _: {"stdout": io.StringIO("feature\n")}, name="git"
+ )
+
+ self.assertEqual("feature", get_current_branch())
+
+ self.assertEqual(
+ [["git", "branch", "--show-current"]],
+ [proc._args["args"] for proc in processes_fixture.procs],
+ )
+
+
+class TestGetCurrentRemoteURL(TestCase):
+ def test_has_no_current_branch(self):
+ processes_fixture = self.useFixture(FakeProcesses())
+ processes_fixture.add(
+ lambda _: {"stdout": io.StringIO("\n")}, name="git"
+ )
+
+ self.assertIsNone(get_current_remote_url())
+
+ self.assertEqual(
+ [["git", "branch", "--show-current"]],
+ [proc._args["args"] for proc in processes_fixture.procs],
+ )
+
+ def test_has_no_current_remote(self):
+ def fake_git(args):
+ if args["args"][1] == "branch":
+ return {"stdout": io.StringIO("feature\n")}
+ elif args["args"][1] == "config":
+ return {"stdout": io.StringIO("\n")}
+ else: # pragma: no cover
+ return {"returncode": 1}
+
+ processes_fixture = self.useFixture(FakeProcesses())
+ processes_fixture.add(fake_git, name="git")
+
+ self.assertIsNone(get_current_remote_url())
+ self.assertEqual(
+ [
+ ["git", "branch", "--show-current"],
+ ["git", "config", "branch.feature.remote"],
+ ],
+ [proc._args["args"] for proc in processes_fixture.procs],
+ )
+
+ def test_has_current_remote(self):
+ def fake_git(args):
+ if args["args"][1] == "branch":
+ return {"stdout": io.StringIO("feature\n")}
+ elif args["args"][1] == "config":
+ return {"stdout": io.StringIO("origin\n")}
+ elif args["args"][1] == "remote":
+ return {"stdout": io.StringIO("git+ssh://git.example.com/\n")}
+ else: # pragma: no cover
+ return {"returncode": 1}
+
+ processes_fixture = self.useFixture(FakeProcesses())
+ processes_fixture.add(fake_git, name="git")
+
+ self.assertEqual(
+ "git+ssh://git.example.com/", get_current_remote_url()
+ )
+ self.assertEqual(
+ [
+ ["git", "branch", "--show-current"],
+ ["git", "config", "branch.feature.remote"],
+ ["git", "remote", "get-url", "origin"],
+ ],
+ [proc._args["args"] for proc in processes_fixture.procs],
+ )
diff --git a/requirements.in b/requirements.in
index 7a73bd5..216669a 100644
--- a/requirements.in
+++ b/requirements.in
@@ -1,5 +1,6 @@
craft-cli
craft-providers
+launchpadlib
pydantic
PyYAML
python-dotenv
diff --git a/requirements.txt b/requirements.txt
index 32ab7f2..301ed96 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,23 +6,61 @@
#
certifi==2022.12.7
# via requests
+cffi==1.15.1
+ # via cryptography
charset-normalizer==2.1.1
# via requests
craft-cli==1.2.0
# via -r requirements.in
craft-providers==1.6.2
# via -r requirements.in
+cryptography==39.0.0
+ # via secretstorage
+distro==1.8.0
+ # via lazr-restfulclient
+httplib2==0.21.0
+ # via
+ # launchpadlib
+ # lazr-restfulclient
idna==3.4
# via requests
-platformdirs==2.6.0
+importlib-metadata==6.0.0
+ # via keyring
+importlib-resources==5.10.2
+ # via keyring
+jaraco-classes==3.2.3
+ # via keyring
+jeepney==0.8.0
+ # via
+ # keyring
+ # secretstorage
+keyring==23.13.1
+ # via launchpadlib
+launchpadlib==1.10.18
+ # via -r requirements.in
+lazr-restfulclient==0.14.5
+ # via launchpadlib
+lazr-uri==1.0.6
+ # via
+ # launchpadlib
+ # wadllib
+more-itertools==9.0.0
+ # via jaraco-classes
+oauthlib==3.2.2
+ # via lazr-restfulclient
+platformdirs==2.6.2
# via craft-cli
pluggy==1.0.0
# via -r requirements.in
-pydantic==1.10.2
+pycparser==2.21
+ # via cffi
+pydantic==1.10.4
# via
# -r requirements.in
# craft-cli
# craft-providers
+pyparsing==3.0.9
+ # via httplib2
python-dotenv==0.21.0
# via -r requirements.in
pyyaml==6.0
@@ -34,7 +72,22 @@ requests==2.28.1
# via requests-unixsocket
requests-unixsocket==0.3.0
# via craft-providers
+secretstorage==3.3.3
+ # via keyring
+six==1.16.0
+ # via
+ # launchpadlib
+ # lazr-restfulclient
typing-extensions==4.4.0
# via pydantic
urllib3==1.26.13
# via requests
+wadllib==1.3.6
+ # via lazr-restfulclient
+zipp==3.11.0
+ # via
+ # importlib-metadata
+ # importlib-resources
+
+# The following packages are considered to be unsafe in a requirements file:
+# setuptools
diff --git a/setup.cfg b/setup.cfg
index d115049..ad08580 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
[metadata]
name = lpcraft
-version = 0.0.40.dev0
+version = 0.1.0.dev0
description = Runner for Launchpad CI jobs
long_description = file: README.rst
long_description_content_type = text/x-rst
@@ -26,6 +26,7 @@ install_requires =
craft-cli
craft-providers
jinja2
+ launchpadlib
pluggy
pydantic
python-dotenv
@@ -41,11 +42,13 @@ docs =
test =
coverage
fixtures
+ launchpadlib[testing]
pdbpp
pytest
responses
systemfixtures
testtools
+ wadllib
[isort]
known_first_party = lpcraft
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
index f1fa9af..7cf5af0 100644
--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -24,6 +24,9 @@ apps:
# help git find its stuff
GIT_TEMPLATE_DIR: $SNAP/git/templates
GIT_EXEC_PATH: $SNAP/git/git-core
+ plugs:
+ # Allow launchpadlib access to the desktop keyring.
+ - desktop
grade: stable
confinement: classic
Follow ups