From a46ee7ab405768ba9ca31517e68deb06b30a42e2 Mon Sep 17 00:00:00 2001 From: Greg Gibeling Date: Tue, 7 May 2024 20:32:24 -0700 Subject: [PATCH] G2-1563 Populate project create with createissues CLI --- lombok.config | 4 + pj-create/.gitignore | 7 + pj-create/pom.xml | 28 ++ .../g2forge/project/plan/create/Changes.java | 21 ++ .../g2forge/project/plan/create/Create.java | 301 ++++++++++++++++++ .../project/plan/create/CreateConfig.java | 37 +++ .../project/plan/create/CreateIssue.java | 72 +++++ .../project/plan/create/ICreateConfig.java | 22 ++ pj-plan/.gitignore | 7 + pj-plan/pom.xml | 28 ++ .../java/com/g2forge/project/plan/Plan.java | 11 + pom.xml | 2 + 12 files changed, 540 insertions(+) create mode 100644 lombok.config create mode 100644 pj-create/.gitignore create mode 100644 pj-create/pom.xml create mode 100644 pj-create/src/main/java/com/g2forge/project/plan/create/Changes.java create mode 100644 pj-create/src/main/java/com/g2forge/project/plan/create/Create.java create mode 100644 pj-create/src/main/java/com/g2forge/project/plan/create/CreateConfig.java create mode 100644 pj-create/src/main/java/com/g2forge/project/plan/create/CreateIssue.java create mode 100644 pj-create/src/main/java/com/g2forge/project/plan/create/ICreateConfig.java create mode 100644 pj-plan/.gitignore create mode 100644 pj-plan/pom.xml create mode 100644 pj-plan/src/main/java/com/g2forge/project/plan/Plan.java diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..c6d9343 --- /dev/null +++ b/lombok.config @@ -0,0 +1,4 @@ +lombok.anyConstructor.addConstructorProperties = true +config.stopBubbling = true +lombok.copyableAnnotations += com.g2forge.gearbox.argparse.Parameter +lombok.copyableAnnotations += com.g2forge.gearbox.argparse.ArgumentHelp \ No newline at end of file diff --git a/pj-create/.gitignore b/pj-create/.gitignore new file mode 100644 index 0000000..5572039 --- /dev/null +++ b/pj-create/.gitignore @@ -0,0 +1,7 @@ +/.project +/.classpath +/.settings/ +/target/ +/bulldozer-temp.json +/bulldozer-state.json +/.factorypath diff --git a/pj-create/pom.xml b/pj-create/pom.xml new file mode 100644 index 0000000..09dd7be --- /dev/null +++ b/pj-create/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + pj-create + + + com.g2forge.project + pj-project + 0.0.1-SNAPSHOT + ../pj-project/pom.xml + + + + + com.g2forge.gearbox + gb-argparse + ${gearbox.version} + + + com.g2forge.gearbox + gb-jira + ${gearbox.version} + + + diff --git a/pj-create/src/main/java/com/g2forge/project/plan/create/Changes.java b/pj-create/src/main/java/com/g2forge/project/plan/create/Changes.java new file mode 100644 index 0000000..43eba48 --- /dev/null +++ b/pj-create/src/main/java/com/g2forge/project/plan/create/Changes.java @@ -0,0 +1,21 @@ +package com.g2forge.project.plan.create; + +import java.util.List; + +import com.atlassian.jira.rest.client.api.domain.input.LinkIssuesInput; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Singular; + +@Data +@Builder +@AllArgsConstructor +class Changes { + @Singular + protected final List issues; + + @Singular + protected final List links; +} diff --git a/pj-create/src/main/java/com/g2forge/project/plan/create/Create.java b/pj-create/src/main/java/com/g2forge/project/plan/create/Create.java new file mode 100644 index 0000000..98c406e --- /dev/null +++ b/pj-create/src/main/java/com/g2forge/project/plan/create/Create.java @@ -0,0 +1,301 @@ +package com.g2forge.project.plan.create; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.slf4j.event.Level; + +import com.atlassian.jira.rest.client.api.IssueRestClient; +import com.atlassian.jira.rest.client.api.domain.BasicComponent; +import com.atlassian.jira.rest.client.api.domain.BasicIssue; +import com.atlassian.jira.rest.client.api.domain.IssueFieldId; +import com.atlassian.jira.rest.client.api.domain.IssuelinksType; +import com.atlassian.jira.rest.client.api.domain.input.ComplexIssueInputFieldValue; +import com.atlassian.jira.rest.client.api.domain.input.FieldInput; +import com.atlassian.jira.rest.client.api.domain.input.IssueInputBuilder; +import com.atlassian.jira.rest.client.api.domain.input.LinkIssuesInput; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.g2forge.alexandria.command.command.IStandardCommand; +import com.g2forge.alexandria.command.exit.IExit; +import com.g2forge.alexandria.command.invocation.CommandInvocation; +import com.g2forge.alexandria.java.core.error.HError; +import com.g2forge.alexandria.log.HLog; +import com.g2forge.gearbox.jira.ExtendedJiraRestClient; +import com.g2forge.gearbox.jira.JIRAServer; + +import io.atlassian.util.concurrent.Promise; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +/** + * A small CLI tool for creating Jira issues in bulk from a YAML file. This is particularly helpful when said issues need complex links between them (e.g. + * dependencies) which are known at issue creation time, as the normal Jira bulk upload options do not allow automatic creation of such links. + * + * Run Create <INPUTFILE> + * + * The INPUTFILE must be a YAML file, which consists of a configuration one field of which is issues. The configuration must specify + * at least a summary for each issue, and optionally more. The fields of the issues in the YAML file are documented below. The configuration can + * also include, at the top level, an entry for any field marked "configurable" whose value will be used for any issue that does not specify a value explicitly. + * Please see {@link JIRAServer} for information on specifying the Jira server and user account. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Create issues issue properties and their descriptions
FieldRequiredConfigurableTypeDescription
projectyesyesStringThe key of the Jira project in which to create the issue(s). Usually 3-4 characters such as PRJ.
typeyesyesStringThe type of the issue(s) to create.
epicnoyesStringThe issue key (PRJ-123) of the epic to add the issue(s) to.
securityLevelnoyesStringThe security level to set on the issue(s).
summaryyesnoStringA short, single-line summary of the issue.
descriptionnonoTextA complete description of the issue. May contain Jira markup formatted text and be many lines of text. The YAML indenting, of course, will be + * removed.
assigneenoyesStringThe username of the person to assign the issue(s) to.
componentsnoyesSet<String>A set of names of the project components to add to this issue. If both the issue & configuration have components, the union of the sets will be + * applied to the issue. Remember components are per-project, if you are creating issues across multiple projects.
labelsnoyesSet<String>A set of labels to apply to the issue(s).  If both the issue & configuration have labels, the union of the sets will be applied to the + * issue.
relationshipsnoyesMap<String, Set<String>>A map from jira links types to the issues to link to this one.  Linked issues may be specified by key if they're already in Jira, or by summary + * if they are to be added by this file.
+ */ +public class Create implements IStandardCommand { + @Data + @Builder + @AllArgsConstructor + protected static class LinkType { + protected final String name; + + protected final boolean reverse; + } + + protected static final Pattern PATTERN_KEY = Pattern.compile("([A-Z0-9]{2,5}-[0-9]+)(\\s.*)?"); + + protected static Changes computeChanges(CreateConfig config) { + final Changes.ChangesBuilder retVal = Changes.builder(); + for (CreateIssue raw : config.getIssues()) { + // Integrate the configuration into the issue & record it + final CreateIssue issue = raw.fallback(config); + retVal.issue(issue); + + // Record all the links + for (String relationship : issue.getRelationships().keySet()) { + for (String target : issue.getRelationships().get(relationship)) { + retVal.link(new LinkIssuesInput(issue.getSummary(), target, relationship, null)); + } + } + } + return retVal.build(); + } + + protected static boolean isKey(String keySummary) { + return PATTERN_KEY.matcher(keySummary).matches(); + } + + public static void main(String[] args) throws Throwable { + IStandardCommand.main(args, new Create()); + } + + protected static void verifyChanges(final Changes changes) { + { // Verify all the links we can + final Set summaries = changes.getIssues().stream().map(CreateIssue::getSummary).collect(Collectors.toSet()); + final List badLinks = new ArrayList<>(); + for (LinkIssuesInput link : changes.getLinks()) { + final String target = link.getToIssueKey(); + if (isKey(target)) continue; + if (!summaries.contains(target)) badLinks.add(String.format("Link target \"%1$s\" <[%2$s]- \"%3$s\" is not valid!", target, link.getLinkType(), link.getFromIssueKey())); + } + if (!badLinks.isEmpty()) throw new IllegalArgumentException("One or more bad links:\n" + badLinks.stream().collect(Collectors.joining("\n"))); + } + } + + protected final Map> projectComponentsCache = new LinkedHashMap<>(); + + public List createIssues(InputStream stream) throws JsonParseException, JsonMappingException, IOException, URISyntaxException, InterruptedException, ExecutionException { + // Load the config, but if it's empty, don't bother + final CreateConfig config = load(stream); + if ((config.getIssues() == null) || config.getIssues().isEmpty()) return Collections.emptyList(); + + final Changes changes = computeChanges(config); + verifyChanges(changes); + return implementChanges(changes); + } + + protected Map getProjectComponents(final ExtendedJiraRestClient client, final String projectKey) { + return projectComponentsCache.computeIfAbsent(projectKey, projectKey2 -> { + final Map retVal = new LinkedHashMap<>(); + Iterable components; + try { + components = client.getProjectComponentsClient().getComponents(projectKey2).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + for (BasicComponent component : components) { + retVal.put(component.getName(), component); + } + return retVal; + }); + } + + protected List implementChanges(Changes changes) throws IOException, URISyntaxException, InterruptedException, ExecutionException { + HLog.getLogControl().setLogLevel(Level.INFO); + try (final ExtendedJiraRestClient client = JIRAServer.load().connect(true)) { + final Map linkTypes = new HashMap<>(); + for (IssuelinksType linkType : client.getMetadataClient().getIssueLinkTypes().get()) { + linkTypes.put(linkType.getName(), new LinkType(linkType.getName(), false)); + linkTypes.put(linkType.getInward(), new LinkType(linkType.getName(), true)); + linkTypes.put(linkType.getOutward(), new LinkType(linkType.getName(), false)); + } + + final IssueRestClient issueClient = client.getIssueClient(); + final Map issues = new LinkedHashMap<>(); + for (CreateIssue issue : changes.getIssues()) { + final IssueInputBuilder builder = new IssueInputBuilder(issue.getProject(), 0l); + builder.setFieldInput(new FieldInput(IssueFieldId.ISSUE_TYPE_FIELD, ComplexIssueInputFieldValue.with("name", issue.getType()))); + if (issue.getEpic() != null) builder.setFieldInput(new FieldInput("customfield_10000", issue.getEpic())); + if (issue.getSecurityLevel() != null) builder.setFieldInput(new FieldInput("security", ComplexIssueInputFieldValue.with("name", issue.getSecurityLevel()))); + builder.setSummary(issue.getSummary()); + builder.setDescription(issue.getDescription()); + if (issue.getAssignee() != null) builder.setAssigneeName(issue.getAssignee()); + if ((issue.getComponents() != null) && !issue.getComponents().isEmpty()) { + final Map components = getProjectComponents(client, issue.getProject()); + builder.setFieldInput(new FieldInput(IssueFieldId.COMPONENTS_FIELD, issue.getComponents().stream().map(name -> { + final BasicComponent component = components.get(name); + if (component == null) throw new IllegalArgumentException(String.format("Component \"%1$s\" was not found in Jira", name)); + return ComplexIssueInputFieldValue.with("id", component.getId().toString()); + }).collect(Collectors.toSet()))); + } + if ((issue.getLabels() != null) && !issue.getLabels().isEmpty()) builder.setFieldInput(new FieldInput(IssueFieldId.LABELS_FIELD, issue.getLabels())); + + final List throwables = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + final Promise promise = issueClient.createIssue(builder.build()); + final BasicIssue created; + try { + created = promise.get(); + } catch (ExecutionException e) { + throwables.add(e); + continue; + } + issues.put(issue.getSummary(), created.getKey()); + throwables.clear(); + break; + } + if (!throwables.isEmpty()) { + HError.withSuppressed(new RuntimeException(String.format("Failed to create issue: %1$s", issue.getSummary())), throwables).printStackTrace(System.err); + } + } + + for (LinkIssuesInput link : changes.getLinks()) { + final LinkType linkType = linkTypes.get(link.getLinkType()); + final String from = issues.get(link.getFromIssueKey()); + final String to = issues.getOrDefault(link.getToIssueKey(), link.getToIssueKey()); + // TODO: Handle it when an issue we're linking wasn't created + issueClient.linkIssue(new LinkIssuesInput(linkType.isReverse() ? to : from, linkType.isReverse() ? from : to, linkType.getName(), link.getComment())).get(); + } + + return new ArrayList<>(issues.values()); + } + } + + @Override + public IExit invoke(CommandInvocation invocation) throws Throwable { + if (invocation.getArguments().size() != 1) throw new IllegalArgumentException(); + final Path input = Paths.get(invocation.getArguments().get(0)); + try (final InputStream stream = Files.newInputStream(input)) { + createIssues(stream).forEach(System.out::println); + } + return IStandardCommand.SUCCESS; + } + + protected CreateConfig load(final InputStream stream) throws IOException, JsonParseException, JsonMappingException { + final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + return mapper.readValue(stream, new TypeReference() {}); + } +} diff --git a/pj-create/src/main/java/com/g2forge/project/plan/create/CreateConfig.java b/pj-create/src/main/java/com/g2forge/project/plan/create/CreateConfig.java new file mode 100644 index 0000000..81d962f --- /dev/null +++ b/pj-create/src/main/java/com/g2forge/project/plan/create/CreateConfig.java @@ -0,0 +1,37 @@ +package com.g2forge.project.plan.create; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Singular; + +@Data +@Builder +@AllArgsConstructor +public class CreateConfig implements ICreateConfig { + protected final String project; + + protected final String type; + + protected final String epic; + + protected final String securityLevel; + + protected final String assignee; + + @Singular + protected final Set components; + + @Singular + protected final Set labels; + + @Singular + protected final List issues; + + @Singular + protected final Map> relationships; +} diff --git a/pj-create/src/main/java/com/g2forge/project/plan/create/CreateIssue.java b/pj-create/src/main/java/com/g2forge/project/plan/create/CreateIssue.java new file mode 100644 index 0000000..94c0c40 --- /dev/null +++ b/pj-create/src/main/java/com/g2forge/project/plan/create/CreateIssue.java @@ -0,0 +1,72 @@ +package com.g2forge.project.plan.create; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.g2forge.alexandria.java.function.IFunction1; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Singular; + +@Data +@Builder +@AllArgsConstructor +public class CreateIssue implements ICreateConfig { + protected final String project; + + protected final String type; + + protected final String epic; + + protected final String securityLevel; + + protected final String summary; + + protected final String description; + + protected final String assignee; + + @Singular + protected final Set components; + + @Singular + protected final Set labels; + + @Singular + protected final Map> relationships; + + public CreateIssue fallback(CreateConfig config) { + final CreateIssueBuilder retVal = builder(); + + // Configurable fields + retVal.project(IFunction1.create(ICreateConfig::getProject).applyWithFallback(this, config)); + retVal.type(IFunction1.create(ICreateConfig::getType).applyWithFallback(this, config)); + retVal.epic(IFunction1.create(ICreateConfig::getEpic).applyWithFallback(this, config)); + retVal.components(Stream.of(this, config).map(ICreateConfig::getComponents).flatMap(l -> l == null ? Stream.empty() : l.stream()).collect(Collectors.toSet())); + retVal.labels(Stream.of(this, config).map(ICreateConfig::getLabels).flatMap(l -> l == null ? Stream.empty() : l.stream()).collect(Collectors.toSet())); + retVal.securityLevel(IFunction1.create(ICreateConfig::getSecurityLevel).applyWithFallback(this, config)); + retVal.assignee(IFunction1.create(ICreateConfig::getAssignee).applyWithFallback(this, config)); + { // Merge the relationships + final Map> relationships = new LinkedHashMap<>(); + for (ICreateConfig createConfig : new ICreateConfig[] { this, config }) { + if (createConfig.getRelationships() != null) for (Map.Entry> entry : createConfig.getRelationships().entrySet()) { + if ((entry.getValue() != null) && !entry.getValue().isEmpty()) relationships.computeIfAbsent(entry.getKey(), k -> new LinkedHashSet()).addAll(entry.getValue()); + } + } + if (!relationships.isEmpty()) retVal.relationships(relationships); + } + + // Per-issue fields + retVal.summary(getSummary()); + retVal.description(getDescription()); + if (getRelationships() != null) retVal.relationships(getRelationships()); + + return retVal.build(); + } +} diff --git a/pj-create/src/main/java/com/g2forge/project/plan/create/ICreateConfig.java b/pj-create/src/main/java/com/g2forge/project/plan/create/ICreateConfig.java new file mode 100644 index 0000000..fb1e695 --- /dev/null +++ b/pj-create/src/main/java/com/g2forge/project/plan/create/ICreateConfig.java @@ -0,0 +1,22 @@ +package com.g2forge.project.plan.create; + +import java.util.Map; +import java.util.Set; + +public interface ICreateConfig { + public String getProject(); + + public String getType(); + + public String getEpic(); + + public String getSecurityLevel(); + + public String getAssignee(); + + public Set getComponents(); + + public Set getLabels(); + + public Map> getRelationships(); +} diff --git a/pj-plan/.gitignore b/pj-plan/.gitignore new file mode 100644 index 0000000..5572039 --- /dev/null +++ b/pj-plan/.gitignore @@ -0,0 +1,7 @@ +/.project +/.classpath +/.settings/ +/target/ +/bulldozer-temp.json +/bulldozer-state.json +/.factorypath diff --git a/pj-plan/pom.xml b/pj-plan/pom.xml new file mode 100644 index 0000000..c247714 --- /dev/null +++ b/pj-plan/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + pj-plan + + + com.g2forge.project + pj-project + 0.0.1-SNAPSHOT + ../pj-project/pom.xml + + + + + com.g2forge.gearbox + gb-argparse + ${gearbox.version} + + + com.g2forge.gearbox + gb-jira + ${gearbox.version} + + + diff --git a/pj-plan/src/main/java/com/g2forge/project/plan/Plan.java b/pj-plan/src/main/java/com/g2forge/project/plan/Plan.java new file mode 100644 index 0000000..724787f --- /dev/null +++ b/pj-plan/src/main/java/com/g2forge/project/plan/Plan.java @@ -0,0 +1,11 @@ +package com.g2forge.project.plan; + +import com.g2forge.alexandria.command.command.DispatchCommand; +import com.g2forge.alexandria.command.command.IStructuredCommand; + +public class Plan implements IStructuredCommand { + public static void main(String[] args) throws Throwable { + final DispatchCommand.ManualBuilder builder = new DispatchCommand.ManualBuilder(); + builder.main(args); + } +} diff --git a/pom.xml b/pom.xml index 7a6fb77..5ef6f19 100644 --- a/pom.xml +++ b/pom.xml @@ -19,5 +19,7 @@ pj-project + pj-create + pj-plan