diff --git a/api/src/main/java/org/itsallcode/openfasttrace/api/FilterSettings.java b/api/src/main/java/org/itsallcode/openfasttrace/api/FilterSettings.java index 7b5019286..b9c84a802 100644 --- a/api/src/main/java/org/itsallcode/openfasttrace/api/FilterSettings.java +++ b/api/src/main/java/org/itsallcode/openfasttrace/api/FilterSettings.java @@ -1,20 +1,25 @@ package org.itsallcode.openfasttrace.api; +import java.util.EnumSet; import java.util.Objects; import java.util.Set; +import org.itsallcode.openfasttrace.api.core.ItemStatus; + /** * Settings for import filtering */ public final class FilterSettings { private final Set artifactTypes; + private final Set wantedStatuses; private final Set tags; private final boolean withoutTags; private FilterSettings(final Builder builder) { this.artifactTypes = builder.artifactTypes; + this.wantedStatuses = builder.wantedStatuses; this.tags = builder.tags; this.withoutTags = builder.withoutTags; } @@ -29,6 +34,16 @@ public Set getArtifactTypes() return Set.copyOf(this.artifactTypes); } + /** + * Get the statuses the filter must match. + * + * @return statuses that must be matched + */ + public Set getWantedStatuses() + { + return Set.copyOf(this.wantedStatuses); + } + /** * Get the tags the filter must match. * @@ -60,6 +75,16 @@ public boolean isArtifactTypeCriteriaSet() return this.artifactTypes != null && !this.artifactTypes.isEmpty(); } + /** + * Check if the status filter is set. + * + * @return {@code true} if the status filter is set + */ + public boolean isStatusCriteriaSet() + { + return this.wantedStatuses != null && !this.wantedStatuses.isEmpty(); + } + /** * Check if the tag filter is set. * @@ -77,13 +102,13 @@ public boolean isTagCriteriaSet() */ public boolean isAnyCriteriaSet() { - return isArtifactTypeCriteriaSet() || isTagCriteriaSet(); + return isArtifactTypeCriteriaSet() || isStatusCriteriaSet() || isTagCriteriaSet(); } @Override public int hashCode() { - return Objects.hash(this.artifactTypes, this.tags, this.withoutTags); + return Objects.hash(this.artifactTypes, this.wantedStatuses, this.tags, this.withoutTags); } @Override @@ -92,7 +117,7 @@ public boolean equals(final Object other) { return false; } return withoutTags == that.withoutTags && Objects.equals(artifactTypes, that.artifactTypes) - && Objects.equals(tags, that.tags); + && Objects.equals(wantedStatuses, that.wantedStatuses) && Objects.equals(tags, that.tags); } /** @@ -122,6 +147,7 @@ public static Builder builder() public static final class Builder { private Set artifactTypes = Set.of(); + private Set wantedStatuses = EnumSet.noneOf(ItemStatus.class); private Set tags = Set.of(); private boolean withoutTags = true; @@ -143,6 +169,19 @@ public Builder artifactTypes(final Set artifactTypes) return this; } + /** + * Set the list of statuses that the filter matches. + * + * @param statuses + * statuses that must be matched + * @return this for fluent programming + */ + public Builder wantedStatuses(final Set statuses) + { + this.wantedStatuses = Set.copyOf(statuses); + return this; + } + /** * Set the list of tags that the filter matches. * diff --git a/api/src/main/java/org/itsallcode/openfasttrace/api/importer/SpecificationListBuilder.java b/api/src/main/java/org/itsallcode/openfasttrace/api/importer/SpecificationListBuilder.java index 8136fe67d..a99ceb6c1 100644 --- a/api/src/main/java/org/itsallcode/openfasttrace/api/importer/SpecificationListBuilder.java +++ b/api/src/main/java/org/itsallcode/openfasttrace/api/importer/SpecificationListBuilder.java @@ -200,9 +200,17 @@ private SpecificationItem createNewSpecificationItem() private boolean isAccepted(final SpecificationItem item) { return isAcceptedArtifactType(item.getArtifactType()) + && isAcceptedStatus(item.getStatus()) && matchesTagsCriteria(item.getTags()); } + // [impl->dsn~filtering-by-item-status-during-import~1] + private boolean isAcceptedStatus(final ItemStatus status) + { + return !this.filterSettings.isStatusCriteriaSet() + || this.filterSettings.getWantedStatuses().contains(status); + } + // [impl->dsn~filtering-by-tags-during-import~1] // [impl->dsn~filtering-by-tags-or-no-tags-during-import~1] private boolean matchesTagsCriteria(final List tags) diff --git a/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestFilterSettings.java b/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestFilterSettings.java index 17168dcc6..da69bed46 100644 --- a/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestFilterSettings.java +++ b/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestFilterSettings.java @@ -8,6 +8,7 @@ import java.util.HashSet; import org.itsallcode.openfasttrace.api.FilterSettings; +import org.itsallcode.openfasttrace.api.core.ItemStatus; import org.junit.jupiter.api.Test; import nl.jqno.equalsverifier.EqualsVerifier; @@ -31,6 +32,17 @@ void testBuilder() assertFilterSet(filterSettings, true); } + @Test + void testBuilderWithStatuses() + { + final ItemStatus[] expectedStatuses = { ItemStatus.APPROVED, ItemStatus.DRAFT }; + final FilterSettings filterSettings = FilterSettings.builder() // + .wantedStatuses(new HashSet<>(Arrays.asList(expectedStatuses))) // + .build(); + assertThat(filterSettings.getWantedStatuses(), containsInAnyOrder(expectedStatuses)); + assertFilterSet(filterSettings, true); + } + private void assertFilterSet(final FilterSettings filterSettings, final boolean set) { assertThat(filterSettings.isAnyCriteriaSet(), equalTo(set)); diff --git a/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestSpecificationListBuilder.java b/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestSpecificationListBuilder.java index 2a2691b52..890d113e1 100644 --- a/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestSpecificationListBuilder.java +++ b/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestSpecificationListBuilder.java @@ -149,6 +149,40 @@ void testDuplicateIdNotIgnored() assertThat(builder.getItemCount(), equalTo(2)); } + // [utest->dsn~filtering-by-item-status-during-import~1] + @Test + void testFilterSpecificationItemsByStatus() + { + final Set wantedStatuses = new HashSet<>(); + wantedStatuses.add(ItemStatus.DRAFT); + final FilterSettings filterSettings = FilterSettings.builder() // + .wantedStatuses(wantedStatuses) // + .build(); + final SpecificationListBuilder builder = SpecificationListBuilder + .createWithFilter(filterSettings); + addItemWithStatus(builder, "in-A", ItemStatus.DRAFT); + addItemWithStatus(builder, "out-B", ItemStatus.APPROVED); + addItemWithStatus(builder, "out-C", ItemStatus.PROPOSED); + addItemWithStatus(builder, "out-D", ItemStatus.REJECTED); + addItemWithStatus(builder, "out-E", null); // becomes APPROVED by default + final List items = builder.build(); + assertThat(items.stream().map(SpecificationItem::getName).toList(), + containsInAnyOrder("in-A")); + } + + private void addItemWithStatus(final SpecificationListBuilder builder, final String name, + final ItemStatus status) + { + builder.beginSpecificationItem(); + final SpecificationItemId id = SpecificationItemId.createId("dsn", name, 1); + builder.setId(id); + if (status != null) + { + builder.setStatus(status); + } + builder.endSpecificationItem(); + } + // [utest->dsn~filtering-by-tags-during-import~1] @Test void testFilterSpecificationItemsByTags() diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliArguments.java b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliArguments.java index c07678b76..238f540d4 100644 --- a/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliArguments.java +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/CliArguments.java @@ -4,10 +4,12 @@ import java.nio.file.Paths; import java.util.*; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.itsallcode.openfasttrace.api.ColorScheme; import org.itsallcode.openfasttrace.api.DetailsSectionDisplay; import org.itsallcode.openfasttrace.api.cli.DirectoryService; +import org.itsallcode.openfasttrace.api.core.ItemStatus; import org.itsallcode.openfasttrace.api.core.Newline; import org.itsallcode.openfasttrace.api.report.ReportConstants; import org.itsallcode.openfasttrace.api.report.ReportVerbosity; @@ -33,6 +35,7 @@ public class CliArguments private String outputFormat; private ReportVerbosity reportVerbosity; private Set wantedArtifactTypes = Collections.emptySet(); + private Set wantedStatuses = Collections.emptySet(); private Set wantedTags = Collections.emptySet(); // [impl->dsn~reporting.plain-text.specification-item-origin~1]] @@ -329,6 +332,45 @@ public void setA(final String artifactTypes) setWantedArtifactTypes(artifactTypes); } + /** + * Get a list of statuses to be applied as a filter during import + * + * @return set of wanted statuses + */ + public Set getWantedStatuses() + { + return Collections.unmodifiableSet(this.wantedStatuses); + } + + /** + * Set a list of statuses to be applied as a filter during import + * + * @param statuses + * list of wanted statuses + */ + public void setWantedStatuses(final String statuses) + { + this.wantedStatuses = createStatusSetFromCommaSeparatedString(statuses); + } + + private static Set createStatusSetFromCommaSeparatedString(final String commaSeparatedString) + { + return COMMA_SEPARATED_PATTERN.splitAsStream(commaSeparatedString) + .map(ItemStatus::parseString) + .collect(Collectors.toSet()); + } + + /** + * Set a list of statuses to be applied as a filter during import + * + * @param statuses + * list of wanted statuses + */ + public void setW(final String statuses) + { + setWantedStatuses(statuses); + } + /** * Get a list of tags to be applied as a filter during import * diff --git a/core/src/main/java/org/itsallcode/openfasttrace/core/cli/commands/AbstractCommand.java b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/commands/AbstractCommand.java index be1d7bd41..2440a6c7a 100644 --- a/core/src/main/java/org/itsallcode/openfasttrace/core/cli/commands/AbstractCommand.java +++ b/core/src/main/java/org/itsallcode/openfasttrace/core/cli/commands/AbstractCommand.java @@ -50,9 +50,19 @@ private FilterSettings createFilterSettingsFromArguments() final FilterSettings.Builder builder = FilterSettings.builder(); setAttributeTypeFilter(builder); setTagFilter(builder); + setStatusFilter(builder); return builder.build(); } + private void setStatusFilter(final FilterSettings.Builder builder) + { + if (this.arguments.getWantedStatuses() != null + && !this.arguments.getWantedStatuses().isEmpty()) + { + builder.wantedStatuses(this.arguments.getWantedStatuses()); + } + } + private void setAttributeTypeFilter(final FilterSettings.Builder builder) { if (this.arguments.getWantedArtifactTypes() != null diff --git a/core/src/main/resources/usage.txt b/core/src/main/resources/usage.txt index 4ea61faff..aab3522ad 100644 --- a/core/src/main/resources/usage.txt +++ b/core/src/main/resources/usage.txt @@ -31,6 +31,8 @@ Common options: Note that this option is ignored when -f is also set. -f, --output-file path The output file. Defaults to STDOUT. + -w, --wanted-statuses Import only specification items that have a + status contained in the comma-separated list -n, --newline format Newline format. One of "unix", "windows", "oldmac" -t, --wanted-tags Import only specification items that have at least one tag contained in the comma-separated diff --git a/core/src/test/java/org/itsallcode/openfasttrace/core/cli/TestCliArguments.java b/core/src/test/java/org/itsallcode/openfasttrace/core/cli/TestCliArguments.java index c538bf67f..87bfe1cf0 100644 --- a/core/src/test/java/org/itsallcode/openfasttrace/core/cli/TestCliArguments.java +++ b/core/src/test/java/org/itsallcode/openfasttrace/core/cli/TestCliArguments.java @@ -9,6 +9,7 @@ import org.itsallcode.openfasttrace.api.ColorScheme; import org.itsallcode.openfasttrace.api.DetailsSectionDisplay; +import org.itsallcode.openfasttrace.api.core.ItemStatus; import org.itsallcode.openfasttrace.api.core.Newline; import org.itsallcode.openfasttrace.api.report.ReportConstants; import org.itsallcode.openfasttrace.api.report.ReportVerbosity; @@ -166,6 +167,33 @@ void testSetA() containsInAnyOrder("impl", "utest")); } + // [utest->dsn~filtering-by-item-status-during-import~1] + @Test + void testWantedStatusesEmptyByDefault() + { + assertThat(BEFORE_SETTER, this.arguments.getWantedStatuses(), emptyIterable()); + } + + // [utest->dsn~filtering-by-item-status-during-import~1] + @Test + void testSetWantedStatuses() + { + final String value = "approved,proposed"; + this.arguments.setWantedStatuses(value); + assertThat(AFTER_SETTER, this.arguments.getWantedStatuses(), + containsInAnyOrder(ItemStatus.APPROVED, ItemStatus.PROPOSED)); + } + + // [utest->dsn~filtering-by-item-status-during-import~1] + @Test + void testSetW() + { + final String value = "draft, proposed"; + this.arguments.setW(value); + assertThat(AFTER_SETTER, this.arguments.getWantedStatuses(), + containsInAnyOrder(ItemStatus.DRAFT, ItemStatus.PROPOSED)); + } + // [utest->dsn~filtering-by-tags-during-import~1] @Test void testWantedTagsEmptyByDefault() diff --git a/doc/changes/changes_4.6.0.md b/doc/changes/changes_4.6.0.md index 99db1dc2f..5ffb81567 100644 --- a/doc/changes/changes_4.6.0.md +++ b/doc/changes/changes_4.6.0.md @@ -1,13 +1,20 @@ # OpenFastTrace 4.6.0, released 2026-07-?? -Code name: ?? - -We also updated test and build dependencies to fix vulnerabilities. Runtime code is not affected, so no update is required. +Code name: Status Filter at Import ## Summary -We moved some GitHub action permissions from workflow-level to job-level. +We added a new feature to filter specification items by status at import time via `-w` or `--wanted-statuses` CLI parameters. +This is helpful when your project uses specification documents for planning future requirements (marked by the status "draft" or "porposed"). +We also updated test and build dependencies to fix vulnerabilities. Runtime code is not affected, so no update is required. + +We moved some GitHub action permissions from workflow-level to job-level. Sonar findings that accumulated with Sonar introducing new code rules were reduced a lot. Since OFT is used in safety-critical projects, code quality is crucial to us. + +## New Features + +* #519: Added support for filtering by specification item status. + ## Security * #556: Updated Junit, PlantUML, Jacoco Maven plugin and Central publishing Plugin dependencies to fix vulnerabilities diff --git a/doc/spec/design.md b/doc/spec/design.md index 237ab4e57..21c9ce5fd 100644 --- a/doc/spec/design.md +++ b/doc/spec/design.md @@ -268,6 +268,17 @@ Covers: Needs: impl, utest, itest +#### Filtering by Item Status During Import +`dsn~filtering-by-item-status-during-import~1` + +The [specification list builder](#specification-list-builder) can be configured to import a specification item only if its status matches at least one of the configured statuses. + +Covers: + +* `req~include-only-item-statuses~1` + +Needs: impl, utest, itest + #### Filtering by Tags During Import `dsn~filtering-by-tags-during-import~1` diff --git a/doc/spec/system_requirements.md b/doc/spec/system_requirements.md index 621723684..c4efb2b41 100644 --- a/doc/spec/system_requirements.md +++ b/doc/spec/system_requirements.md @@ -504,6 +504,17 @@ Covers: Needs: dsn +#### Include Only Item Statuses +`req~include-only-item-statuses~1` + +OFT gives users the option to include only specification items with a configurable set of statuses during processing. + +Covers: + +* [feat~requirement-tracing~1](#requirement-tracing) + +Needs: dsn + #### Include Items Where at Least One Tag Matches `req~include-items-where-at-least-on-tag-matches~1` diff --git a/doc/user_guide.md b/doc/user_guide.md index 7139671a4..3dcb422a4 100644 --- a/doc/user_guide.md +++ b/doc/user_guide.md @@ -270,7 +270,7 @@ Keywords are followed by a colon that separates the keyword from the content. De ##### `Status` -The `Status` keyword takes a single value from `draft`, `proposed`, `approved` to set the status of the item. At the moment this has no effect on the HTML or plaintext output, but only if the `-o aspec` option is used (see [XML Tracing Report](#xml-tracing-report)). Has to occur before the `Description`, `Rationale` or `Comment`. +The `Status` keyword takes a single value from `draft`, `proposed`, `approved`, `rejected` to set the status of the item. The status can be used to filter specification items during import (see [Import options](#import-options)). Has to occur before the `Description`, `Rationale` or `Comment`. ### A draft spec items `req~draft-item~1` @@ -441,6 +441,18 @@ If you want to also import specification items that do not have any tags, add a oft convert -t _,AuthenticationProvider,ServiceDiscovery,MapProvider import/arch/ > arch_filtered_by_web_services.xml +### Filtering by Status + +Sometimes you only want to see specification items that have reached a certain maturity level. For example, you might want to create a report that only includes approved requirements. + +To achieve this, you can filter by status using the `-w` or `--wanted-statuses` option: + + oft trace -w approved doc/ + +This tells OFT to only import specification items that have the status `approved`. You can also provide a comma-separated list of statuses: + + oft trace -w approved,proposed doc/ + ### Tracing the Whole Chain If you plan to assess the coverage state of your product as a whole, you need to trace the full chain including all artifacts. @@ -601,6 +613,10 @@ The following commands are equivalent and all display the command line usage and Import only specification items where the artifact type matches one of the listed types. + -w, --wanted-statuses [,...] + +Import only specification items that have a status contained in the comma-separated list of statuses. + -t, --wanted-tags [_,][,...] Import only specification items that have at least one of the listed tags. If you add a single underscore "_" as the first entry in the list, specification items that have no tags at all are also imported. diff --git a/product/src/test/java/org/itsallcode/openfasttrace/cli/CliStarterInternalIT.java b/product/src/test/java/org/itsallcode/openfasttrace/cli/CliStarterInternalIT.java index ead90071f..a75b74e2b 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/cli/CliStarterInternalIT.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/cli/CliStarterInternalIT.java @@ -40,6 +40,7 @@ class CliStarterInternalIT { private static final String REPORT_VERBOSITY_PARAMETER = "--report-verbosity"; private static final String OUTPUT_FORMAT_PARAMETER = "--output-format"; private static final String WANTED_ARTIFACT_TYPES_PARAMETER = "--wanted-artifact-types"; + private static final String WANTED_STATUSES_PARAMETER = "--wanted-statuses"; private static final String COLOR_SCHEME_PARAMETER = "--color-scheme"; private static final String CARRIAGE_RETURN = "\r"; private static final String NEWLINE = "\n"; @@ -305,6 +306,41 @@ void testTraceWithFilteredArtifactType() { ); } + @Test + // [itest->dsn~filtering-by-item-status-during-import~1] + void testTraceWithFilteredStatuses(@TempDir final Path tempDir) throws IOException { + final Path specFile = tempDir.resolve("spec.md"); + Files.writeString(specFile, """ + # Spec + ## Draft Re + `req~draft~1` + + Status: draft + + ## Approved Req + `req~approved~1` + Status: approved + + ## Proposed Req + `req~proposed~1` + Status: proposed + """); + + final ExitStatus status = runInternal( + TRACE_COMMAND, tempDir.toString(), + OUTPUT_FILE_PARAMETER, this.outputFile.toString(), + WANTED_STATUSES_PARAMETER, "draft, proposed", + REPORT_VERBOSITY_PARAMETER, "all"); + assertAll( + () -> assertThat(status, equalTo(ExitStatus.OK)), + () -> assertOutputFileExists(true), + () -> assertThat(getOutputFileContent(), containsString("2 total")), + () -> assertThat(getOutputFileContent(), containsString("req~draft~1")), + () -> assertThat(getOutputFileContent(), containsString("req~proposed~1")), + () -> assertThat(getOutputFileContent(), not(containsString("approved"))) + ); + } + private void assertOutputFileContainsOldMacNewlines() { assertThat("Has old Mac newlines", getOutputFileContent().contains(CARRIAGE_RETURN), equalTo(true));