blob: 865bfd13be711546663595312c33d2dab067b574 [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Binary main converts Jiri source manifests (JSON) into Repo manifests (XML).
//
// This tool is specific to Fuchsia in that it only understands the subset of either
// manifest schema sufficient for producing Repo manifests that can be used with go/ab.
//
// The key elements of the Repo manifest are:
// * <remote>
// * <default>
// * <project>
//
// <default> is mostly filled from user-input and specifies the default GoB remote and
// branch for <project> elements in the manifest.
//
// <remote> and <project> elements are built from the entries of the "directories"
// property from the input source manifest.
//
// Source manifests are created relative to some working directory, so there is usually
// (always?) a directory entry named ".". This is represented in the Repo manifest by a
// <project> whose name attribute is the basename of the directory>git_checkout>repo_url.
//
// For documentation on Repo manifests, see go/repo-manifests-explained and go/repo.
//
// For documentation on Jiri manifest, see this proto:
// https://chromium.googlesource.com/external/github.com/luci/recipes-py/+/refs/heads/master/recipe_engine/source_manifest.proto
package main
import (
"context"
"errors"
"flag"
"fmt"
"html/template"
"io"
"log"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"go.fuchsia.dev/infra/cmd/jiri2repo/jiri"
"go.fuchsia.dev/infra/cmd/jiri2repo/repo"
)
var bin = filepath.Base(os.Args[0])
// Command-line flags.
var (
// The release branch name.
//
// This is used as the <default> revision in the repo manifest.
branch string
// The GoB host that will house the output Repo manifest.
//
// This is set as the <default> remote in the repo manifest.
defaultRemote string
// Where to write output. Defaults to stdout.
output string
)
const gobHostSuffix = ".googlesource.com"
var usageTemplate = template.Must(template.New("usage").Parse(`
{{.Cmd}} [options] SOURCE_MANIFEST_PATH
Converts a Jiri source manifest to a Repo manifest.
EXAMPLES:
{{.Cmd}} -branch releases/canary -remote fuchsia ./source_manifest.json
{{.Cmd}} -remote fuchsia ./source_manifest.json
{{.Cmd}} -remote fuchsia -output default.xml ./source_manifest.json
OPTIONS:
`))
func usage() {
usageArgs := struct{ Cmd string }{Cmd: bin}
usageTemplate.Execute(flag.CommandLine.Output(), usageArgs)
flag.PrintDefaults()
os.Exit(1)
}
func init() {
flag.Usage = usage
flag.StringVar(&branch, "branch", "refs/heads/master", "The release branch")
flag.StringVar(&output, "output", "", "Optional filepath to write output to. If empty, stdout is used")
flag.StringVar(&defaultRemote, "remote", "", "The SSO GoB host that will house the output Repo manifest")
}
func main() {
flag.Parse()
if err := validateArgs(); err != nil {
log.Print(err)
flag.Usage()
}
if err := execute(context.Background()); err != nil {
log.Fatal(err)
}
}
func validateArgs() error {
if flag.NArg() != 1 {
return errors.New("expected one positional argument")
}
if branch == "" {
return errors.New("missing -branch")
}
if defaultRemote == "" {
return errors.New("missing -remote")
}
return nil
}
func execute(ctx context.Context) (err error) {
var in jiri.SourceManifest
var out repo.Manifest
sourceManifestPath := flag.Arg(0)
if err := jiri.ReadSourceManifestFile(sourceManifestPath, &in); err != nil {
return fmt.Errorf("failed to read %s: %v", sourceManifestPath, err)
}
defaults := repo.Default{
Branch: branch,
Remote: defaultRemote,
}
if err := convert(in, &out, defaults); err != nil {
return fmt.Errorf("conversion failed: %v", err)
}
var w io.Writer = os.Stdout
if output != "" {
w, err = os.Create(output)
if err != nil {
return fmt.Errorf("failed to open output file %q: %v", output, err)
}
}
if err := repo.WriteManifest(w, &out); err != nil {
return fmt.Errorf("failed to write Repo manifest: %v", err)
}
return nil
}
// Converts the input source manifest to a Repo manifest. Writes the output to out.
//
// defaults is written as the Repo manifest's <default> element. Any ".googlesource.com"
// suffix is stripped from defaults.Remote before writing the output.
func convert(in jiri.SourceManifest, out *repo.Manifest, defaults repo.Default) error {
// Tracks GoB remote names to avoid outputting duplicate <remote> elements.
remotes := make(map[string]bool)
// Remotes are recorded without GoB host suffixes in Repo manifests.
defaults.Remote = strings.TrimSuffix(defaults.Remote, gobHostSuffix)
// Convert each source manifest directory to a <project> and <remote>
for name, dir := range in.Directories {
project, remote, err := convertDirectory(name, *dir, defaults)
if err != nil {
return err
}
out.Project = append(out.Project, *project)
if _, ok := remotes[remote.Name]; !ok {
remotes[remote.Name] = true
out.Remote = append(out.Remote, *remote)
}
}
// If there is no <remote> with the specified GoB remote name, either the user
// mistakenly entered the wrong default remote or the output Repo manifest will be
// stored on a git host that is separate from any of the repos listed in the manifest.
// The second scenario is unlikely; Err on the side of caution and fail.
if !remotes[defaults.Remote] {
return fmt.Errorf("the input manifest contained no projects from the specified default remote %q", defaults.Remote)
}
out.Default = defaults
out.Comment = fmt.Sprintf("Auto generated by %s. DO NOT EDIT", bin)
// Sort sections for deterministic output.
sort.Slice(out.Remote, func(a, b int) bool {
return out.Remote[a].Name < out.Remote[b].Name
})
sort.Slice(out.Project, func(a, b int) bool {
return out.Project[a].Name < out.Project[b].Name
})
return nil
}
// convertDirectory converts a Jiri source manifest directory object into a set of Repo
// <project> and <remote> elements.
//
// The directory name is used as the project's name and path. If the directory name is ".",
// the basename of directory's repository url is used instead. The empty string is not
// allowed and results in an error.
func convertDirectory(name string, d jiri.SourceManifest_Directory, defaults repo.Default) (*repo.Project, *repo.Remote, error) {
// Resolve the directory name.
if name == "." {
name = filepath.Base(d.GitCheckout.RepoUrl)
}
if name == "" {
return nil, nil, fmt.Errorf("source manifest directory has no name: %v", d)
}
checkout := d.GitCheckout
reviewURL, err := ssoCodeReviewURL(checkout.RepoUrl)
if err != nil {
return nil, nil, err
}
remote := &repo.Remote{
// By convention the remote name is the GoB hostname and not the review hostname.
Name: strings.TrimSuffix(reviewURL.Host, "-review"),
Fetch: checkout.FetchRef,
Review: reviewURL.String(),
}
project := &repo.Project{
Name: name,
Path: name,
Remote: remote.Name,
Revision: checkout.Revision,
}
if project.Remote == defaults.Remote {
project.Remote = ""
}
return project, remote, nil
}
func ssoCodeReviewURL(gobURL string) (*url.URL, error) {
u, err := url.Parse(gobURL)
if err != nil {
return nil, err
}
// URL.Hostname() returns the empty string if the scheme is empty.
if u.Scheme == "" {
return nil, fmt.Errorf("invalid URL %q: origin has no scheme", gobURL)
}
hostname := u.Hostname()
if !strings.HasSuffix(hostname, gobHostSuffix) {
return nil, fmt.Errorf("cannot compute code review host for non Git-on-Borg hostname %q", hostname)
}
host := strings.TrimSuffix(hostname, gobHostSuffix)
return &url.URL{
Scheme: "sso",
Host: host + "-review",
Path: "/",
}, nil
}