mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
fix(ISSUE-64): close compliance review gaps
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
eb2db152fd
commit
e17e3e2d09
20 changed files with 199 additions and 52 deletions
|
|
@ -177,7 +177,7 @@ public class SkillController extends BaseApiController {
|
|||
detail.manifestJson(),
|
||||
detail.complianceMappings().stream()
|
||||
.map(mapping -> new SkillComplianceMappingResponse(
|
||||
mapping.standard().value(),
|
||||
mapping.standard(),
|
||||
mapping.standardVersion(),
|
||||
mapping.controlId(),
|
||||
mapping.controlTitle(),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.portal;
|
|||
|
||||
import com.iflytek.skillhub.controller.BaseApiController;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
|
|
@ -40,6 +41,10 @@ public class SkillSearchController extends BaseApiController {
|
|||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) String namespace,
|
||||
@RequestParam(name = "label", required = false) java.util.List<String> labels,
|
||||
@Parameter(
|
||||
name = "complianceStandard",
|
||||
schema = @Schema(implementation = ComplianceStandard.class)
|
||||
)
|
||||
@RequestParam(required = false) String complianceStandard,
|
||||
@Parameter(schema = @Schema(defaultValue = DEFAULT_SORT))
|
||||
@RequestParam(required = false) String sort,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
|
||||
|
||||
public record SkillComplianceMappingResponse(
|
||||
String standard,
|
||||
ComplianceStandard standard,
|
||||
String standardVersion,
|
||||
String controlId,
|
||||
String controlTitle,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.search.SearchQuery;
|
||||
|
|
@ -169,7 +170,7 @@ public class SkillSearchAppService {
|
|||
int page,
|
||||
int size,
|
||||
List<String> labelSlugs,
|
||||
String complianceStandard,
|
||||
ComplianceStandard complianceStandard,
|
||||
SearchVisibilityScope scope,
|
||||
boolean requireInstallableLatest) {
|
||||
SearchResult result = searchQueryService.search(new SearchQuery(
|
||||
|
|
@ -198,11 +199,11 @@ public class SkillSearchAppService {
|
|||
.toList();
|
||||
}
|
||||
|
||||
private String normalizeComplianceStandard(String complianceStandard) {
|
||||
private ComplianceStandard normalizeComplianceStandard(String complianceStandard) {
|
||||
if (complianceStandard == null || complianceStandard.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return complianceStandard.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
return ComplianceStandard.findByValue(complianceStandard).orElse(null);
|
||||
}
|
||||
|
||||
private List<SkillSummaryResponse> mapVisibleSkillSummaries(List<Long> skillIds) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||
import static org.mockito.ArgumentMatchers.anySet;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
|
|
@ -127,6 +129,16 @@ class SkillControllerTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiShouldExposeClosedComplianceStandardEnums() throws Exception {
|
||||
mockMvc.perform(get("/v3/api-docs"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("\"SkillComplianceMappingResponse\"")))
|
||||
.andExpect(content().string(containsString("\"standard\"")))
|
||||
.andExpect(content().string(containsString("\"enum\":[\"mitre_attack\",\"nist_csf\",\"gdpr\",\"hipaa\",\"soc2\"]")))
|
||||
.andExpect(content().string(containsString("\"name\":\"complianceStandard\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveVersionShouldReturnUnifiedEnvelope() throws Exception {
|
||||
when(skillQueryService.resolveVersion(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
|
||||
import com.iflytek.skillhub.search.SearchQuery;
|
||||
import com.iflytek.skillhub.search.SearchQueryService;
|
||||
|
|
@ -313,7 +314,7 @@ class SkillSearchAppServiceTest {
|
|||
|
||||
ArgumentCaptor<SearchQuery> captor = ArgumentCaptor.forClass(SearchQuery.class);
|
||||
verify(searchQueryService).search(captor.capture());
|
||||
assertEquals("gdpr", captor.getValue().complianceStandard());
|
||||
assertEquals(ComplianceStandard.GDPR, captor.getValue().complianceStandard());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -37,15 +37,21 @@ public class SkillComplianceAuditDetailFactory {
|
|||
return build("latest_published_removed", version, extras);
|
||||
}
|
||||
|
||||
public String build(String snapshotKind, SkillVersion version, Map<String, Object> extras) {
|
||||
public String latestPublishedRemoved(SkillVersion version,
|
||||
SkillVersion replacementLatestPublished,
|
||||
Map<String, Object> extras) {
|
||||
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("snapshotKind", snapshotKind);
|
||||
payload.put("versionId", version.getId());
|
||||
payload.put("version", version.getVersion());
|
||||
payload.put(
|
||||
"compliance",
|
||||
complianceMetadataService.readFromParsedMetadataJson(version.getParsedMetadataJson())
|
||||
);
|
||||
if (extras != null && !extras.isEmpty()) {
|
||||
payload.putAll(extras);
|
||||
}
|
||||
if (replacementLatestPublished != null) {
|
||||
payload.put("replacementLatestPublished", snapshotPayload("latest_published_entered", replacementLatestPublished));
|
||||
}
|
||||
return build("latest_published_removed", version, payload);
|
||||
}
|
||||
|
||||
public String build(String snapshotKind, SkillVersion version, Map<String, Object> extras) {
|
||||
LinkedHashMap<String, Object> payload = snapshotPayload(snapshotKind, version);
|
||||
if (extras != null && !extras.isEmpty()) {
|
||||
payload.putAll(extras);
|
||||
}
|
||||
|
|
@ -55,4 +61,16 @@ public class SkillComplianceAuditDetailFactory {
|
|||
throw new IllegalStateException("Failed to serialize compliance audit detail", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> snapshotPayload(String snapshotKind, SkillVersion version) {
|
||||
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("snapshotKind", snapshotKind);
|
||||
payload.put("versionId", version.getId());
|
||||
payload.put("version", version.getVersion());
|
||||
payload.put(
|
||||
"compliance",
|
||||
complianceMetadataService.readFromParsedMetadataJson(version.getParsedMetadataJson())
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,13 +270,14 @@ public class SkillGovernanceService {
|
|||
version.setYankReason(reason);
|
||||
version.setDownloadReady(false);
|
||||
SkillVersion saved = skillVersionRepository.save(version);
|
||||
skillRepository.findById(version.getSkillId()).ifPresent(skill -> {
|
||||
if (versionId.equals(skill.getLatestVersionId())) {
|
||||
skill.setLatestVersionId(findLatestPublishedVersionId(skill.getId()));
|
||||
skill.setUpdatedBy(actorUserId);
|
||||
skillRepository.save(skill);
|
||||
}
|
||||
});
|
||||
SkillVersion replacementLatestPublished = null;
|
||||
Skill skill = skillRepository.findById(version.getSkillId()).orElse(null);
|
||||
if (skill != null && versionId.equals(skill.getLatestVersionId())) {
|
||||
replacementLatestPublished = findLatestPublishedVersion(skill.getId());
|
||||
skill.setLatestVersionId(replacementLatestPublished != null ? replacementLatestPublished.getId() : null);
|
||||
skill.setUpdatedBy(actorUserId);
|
||||
skillRepository.save(skill);
|
||||
}
|
||||
LinkedHashMap<String, Object> auditExtras = new LinkedHashMap<>();
|
||||
if (reason != null && !reason.isBlank()) {
|
||||
auditExtras.put("reason", reason);
|
||||
|
|
@ -289,20 +290,36 @@ public class SkillGovernanceService {
|
|||
null,
|
||||
clientIp,
|
||||
userAgent,
|
||||
complianceAuditDetailFactory.latestPublishedRemoved(version, auditExtras)
|
||||
complianceAuditDetailFactory.latestPublishedRemoved(version, replacementLatestPublished, auditExtras)
|
||||
);
|
||||
if (replacementLatestPublished != null) {
|
||||
auditLogService.record(
|
||||
actorUserId,
|
||||
"YANK_SKILL_VERSION",
|
||||
"SKILL_VERSION",
|
||||
replacementLatestPublished.getId(),
|
||||
null,
|
||||
clientIp,
|
||||
userAgent,
|
||||
complianceAuditDetailFactory.latestPublishedEntered(replacementLatestPublished, auditExtras)
|
||||
);
|
||||
}
|
||||
eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent(
|
||||
version.getSkillId(), versionId, actorUserId));
|
||||
return saved;
|
||||
}
|
||||
|
||||
private Long findLatestPublishedVersionId(Long skillId) {
|
||||
SkillVersion latestPublishedVersion = findLatestPublishedVersion(skillId);
|
||||
return latestPublishedVersion != null ? latestPublishedVersion.getId() : null;
|
||||
}
|
||||
|
||||
private SkillVersion findLatestPublishedVersion(Long skillId) {
|
||||
return skillVersionRepository.findBySkillIdAndStatus(skillId, SkillVersionStatus.PUBLISHED).stream()
|
||||
.max(java.util.Comparator
|
||||
.comparing(SkillVersion::getPublishedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
|
||||
.thenComparing(SkillVersion::getCreatedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
|
||||
.thenComparing(SkillVersion::getId, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())))
|
||||
.map(SkillVersion::getId)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.argThat;
|
|||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
|
|
@ -38,6 +39,7 @@ import org.junit.jupiter.api.AfterEach;
|
|||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
|
|
@ -212,11 +214,37 @@ class SkillGovernanceServiceTest {
|
|||
setField(yanked, "id", 22L);
|
||||
yanked.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
yanked.setPublishedAt(Instant.parse("2026-03-18T10:00:00Z"));
|
||||
yanked.setParsedMetadataJson("""
|
||||
{
|
||||
"frontmatter": {
|
||||
"x-astron-compliance": [
|
||||
{
|
||||
"standard": "gdpr",
|
||||
"standardVersion": "2024",
|
||||
"controlId": "Article-17"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
SkillVersion fallback = new SkillVersion(2L, "1.0.0", "owner");
|
||||
setField(fallback, "id", 11L);
|
||||
fallback.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
fallback.setPublishedAt(Instant.parse("2026-03-17T10:00:00Z"));
|
||||
fallback.setParsedMetadataJson("""
|
||||
{
|
||||
"frontmatter": {
|
||||
"x-astron-compliance": [
|
||||
{
|
||||
"standard": "soc2",
|
||||
"standardVersion": "2017",
|
||||
"controlId": "CC6.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
|
||||
setField(skill, "id", 2L);
|
||||
|
|
@ -232,6 +260,33 @@ class SkillGovernanceServiceTest {
|
|||
|
||||
assertThat(skill.getLatestVersionId()).isEqualTo(11L);
|
||||
verify(skillRepository).save(skill);
|
||||
|
||||
ArgumentCaptor<Long> entityIdCaptor = ArgumentCaptor.forClass(Long.class);
|
||||
ArgumentCaptor<String> detailCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(auditLogService, times(2)).record(
|
||||
org.mockito.ArgumentMatchers.eq("admin"),
|
||||
org.mockito.ArgumentMatchers.eq("YANK_SKILL_VERSION"),
|
||||
org.mockito.ArgumentMatchers.eq("SKILL_VERSION"),
|
||||
entityIdCaptor.capture(),
|
||||
org.mockito.ArgumentMatchers.eq(null),
|
||||
org.mockito.ArgumentMatchers.eq("127.0.0.1"),
|
||||
org.mockito.ArgumentMatchers.eq("JUnit"),
|
||||
detailCaptor.capture()
|
||||
);
|
||||
assertThat(entityIdCaptor.getAllValues()).containsExactlyInAnyOrder(22L, 11L);
|
||||
assertThat(detailCaptor.getAllValues()).anySatisfy(detail -> {
|
||||
assertThat(detail).contains("\"snapshotKind\":\"latest_published_removed\"");
|
||||
assertThat(detail).contains("\"versionId\":22");
|
||||
assertThat(detail).contains("\"replacementLatestPublished\"");
|
||||
assertThat(detail).contains("\"snapshotKind\":\"latest_published_entered\"");
|
||||
assertThat(detail).contains("\"versionId\":11");
|
||||
assertThat(detail).contains("\"standard\":\"soc2\"");
|
||||
});
|
||||
assertThat(detailCaptor.getAllValues()).anySatisfy(detail -> {
|
||||
assertThat(detail).contains("\"snapshotKind\":\"latest_published_entered\"");
|
||||
assertThat(detail).contains("\"versionId\":11");
|
||||
assertThat(detail).contains("\"standard\":\"soc2\"");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.search;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
|
|
@ -13,7 +14,7 @@ public record SearchQuery(
|
|||
int page,
|
||||
int size,
|
||||
List<String> labelSlugs,
|
||||
String complianceStandard,
|
||||
ComplianceStandard complianceStandard,
|
||||
boolean requireInstallableLatest
|
||||
) {
|
||||
public SearchQuery(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.iflytek.skillhub.search.postgres;
|
|||
|
||||
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
|
||||
import com.iflytek.skillhub.search.SearchEmbeddingService;
|
||||
import com.iflytek.skillhub.search.SearchQuery;
|
||||
import com.iflytek.skillhub.search.SearchQueryService;
|
||||
|
|
@ -275,11 +276,11 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
return new SearchResult(skillIds, total, query.page(), query.size());
|
||||
}
|
||||
|
||||
private String normalizeComplianceStandard(String complianceStandard) {
|
||||
if (complianceStandard == null || complianceStandard.isBlank()) {
|
||||
private String normalizeComplianceStandard(ComplianceStandard complianceStandard) {
|
||||
if (complianceStandard == null) {
|
||||
return null;
|
||||
}
|
||||
return complianceStandard.trim().toLowerCase(Locale.ROOT);
|
||||
return complianceStandard.value();
|
||||
}
|
||||
|
||||
private List<Long> rerankBySemanticSimilarity(List<Long> candidateSkillIds,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.search.postgres;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.metadata.ComplianceStandard;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
|
||||
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
|
||||
import com.iflytek.skillhub.search.HashingSearchEmbeddingService;
|
||||
|
|
@ -318,7 +319,7 @@ class PostgresFullTextQueryServiceTest {
|
|||
0,
|
||||
20,
|
||||
List.of("official"),
|
||||
"gdpr",
|
||||
ComplianceStandard.GDPR,
|
||||
false
|
||||
));
|
||||
|
||||
|
|
|
|||
4
web/src/api/generated/schema.d.ts
vendored
4
web/src/api/generated/schema.d.ts
vendored
|
|
@ -4164,7 +4164,7 @@ export interface components {
|
|||
complianceMappings?: components["schemas"]["SkillComplianceMappingResponse"][];
|
||||
};
|
||||
SkillComplianceMappingResponse: {
|
||||
standard?: string;
|
||||
standard?: "mitre_attack" | "nist_csf" | "gdpr" | "hipaa" | "soc2";
|
||||
standardVersion?: string;
|
||||
controlId?: string;
|
||||
controlTitle?: string;
|
||||
|
|
@ -8516,7 +8516,7 @@ export interface operations {
|
|||
q?: string;
|
||||
namespace?: string;
|
||||
label?: string[];
|
||||
complianceStandard?: string;
|
||||
complianceStandard?: "mitre_attack" | "nist_csf" | "gdpr" | "hipaa" | "soc2";
|
||||
sort?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ export interface SkillVersion {
|
|||
}
|
||||
|
||||
export interface SkillComplianceMapping {
|
||||
standard: string
|
||||
standard: ComplianceStandard
|
||||
standardVersion: string
|
||||
controlId: string
|
||||
controlTitle?: string
|
||||
|
|
@ -332,12 +332,20 @@ export interface SkillTag {
|
|||
createdAt: string
|
||||
}
|
||||
|
||||
export const COMPLIANCE_STANDARD_VALUES = ['mitre_attack', 'nist_csf', 'gdpr', 'hipaa', 'soc2'] as const
|
||||
|
||||
export type ComplianceStandard = (typeof COMPLIANCE_STANDARD_VALUES)[number]
|
||||
|
||||
export function isComplianceStandard(value: string): value is ComplianceStandard {
|
||||
return COMPLIANCE_STANDARD_VALUES.some((candidate) => candidate === value)
|
||||
}
|
||||
|
||||
// Search and pagination
|
||||
export interface SearchParams {
|
||||
q?: string
|
||||
namespace?: string
|
||||
label?: string
|
||||
complianceStandard?: string
|
||||
complianceStandard?: ComplianceStandard
|
||||
sort?: string
|
||||
page?: number
|
||||
size?: number
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { lazy, Suspense, type ComponentType } from 'react'
|
||||
import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router'
|
||||
import type { ComplianceStandard } from '@/api/types'
|
||||
import { Layout } from './layout'
|
||||
import { getCurrentUser } from '@/api/client'
|
||||
import { RoleGuard } from '@/shared/components/role-guard'
|
||||
import { createRequireAuth } from '@/shared/lib/auth-route'
|
||||
import { normalizeSearchQuery } from '@/shared/lib/search-query'
|
||||
import { isComplianceStandard } from '@/api/types'
|
||||
|
||||
/**
|
||||
* Central route registry for the SkillHub web app.
|
||||
|
|
@ -199,12 +201,13 @@ const searchRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: 'search',
|
||||
component: SearchPage,
|
||||
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; complianceStandard?: string; sort: string; page: number; starredOnly: boolean } => {
|
||||
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; complianceStandard?: ComplianceStandard; sort: string; page: number; starredOnly: boolean } => {
|
||||
const rawComplianceStandard = typeof search.complianceStandard === 'string' ? search.complianceStandard : ''
|
||||
return {
|
||||
q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''),
|
||||
namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined,
|
||||
label: typeof search.label === 'string' && search.label ? search.label : undefined,
|
||||
complianceStandard: typeof search.complianceStandard === 'string' && search.complianceStandard ? search.complianceStandard : undefined,
|
||||
complianceStandard: isComplianceStandard(rawComplianceStandard) ? rawComplianceStandard : undefined,
|
||||
sort: (search.sort as string) || 'newest',
|
||||
page: Number(search.page) || 0,
|
||||
starredOnly: search.starredOnly === true || search.starredOnly === 'true',
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ describe('install-command', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('adds an explicit version to the ClawHub install command when a version is selected', () => {
|
||||
expect(buildInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0')).toBe(
|
||||
'npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn --version 0.9.0',
|
||||
)
|
||||
})
|
||||
|
||||
it('builds a one-line SkillHub npx command for the global namespace', () => {
|
||||
expect(buildSkillhubInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe(
|
||||
'npx @astron-team/skillhub@latest install my-skill --registry https://skill.xfyun.cn',
|
||||
|
|
@ -80,6 +86,12 @@ describe('install-command', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('adds an explicit version to the SkillHub install command when a version is selected', () => {
|
||||
expect(buildSkillhubInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn', '0.9.0')).toBe(
|
||||
'npx @astron-team/skillhub@latest install my-skill --namespace team-alpha --registry https://skill.xfyun.cn --version 0.9.0',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the runtime app base url when available', () => {
|
||||
setMockWindow('https://app.example.com')
|
||||
|
||||
|
|
|
|||
|
|
@ -29,14 +29,21 @@ export function getBaseUrl(): string {
|
|||
return `${window.location.protocol}//${window.location.host}`
|
||||
}
|
||||
|
||||
export function buildInstallCommand(namespace: string, slug: string, baseUrl: string): string {
|
||||
const installTarget = buildInstallTarget(namespace, slug)
|
||||
return `npx clawhub install ${installTarget} --registry ${baseUrl}`
|
||||
function buildVersionedCommand(command: string, version?: string): string {
|
||||
if (!version) {
|
||||
return command
|
||||
}
|
||||
return `${command} --version ${version}`
|
||||
}
|
||||
|
||||
export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string): string {
|
||||
export function buildInstallCommand(namespace: string, slug: string, baseUrl: string, version?: string): string {
|
||||
const installTarget = buildInstallTarget(namespace, slug)
|
||||
return buildVersionedCommand(`npx clawhub install ${installTarget} --registry ${baseUrl}`, version)
|
||||
}
|
||||
|
||||
export function buildSkillhubInstallCommand(namespace: string, slug: string, baseUrl: string, version?: string): string {
|
||||
const namespaceArg = namespace === 'global' ? '' : ` --namespace ${namespace}`
|
||||
return `npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}`
|
||||
return buildVersionedCommand(`npx @astron-team/skillhub@latest install ${slug}${namespaceArg} --registry ${baseUrl}`, version)
|
||||
}
|
||||
|
||||
interface CommandBlockProps {
|
||||
|
|
@ -80,11 +87,11 @@ function CommandBlock({ command }: CommandBlockProps) {
|
|||
)
|
||||
}
|
||||
|
||||
export function InstallCommand({ namespace, slug }: InstallCommandProps) {
|
||||
export function InstallCommand({ namespace, slug, version }: InstallCommandProps) {
|
||||
const { t } = useTranslation()
|
||||
const baseUrl = useMemo(() => getBaseUrl(), [])
|
||||
const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
|
||||
const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl), [baseUrl, namespace, slug])
|
||||
const clawhubCommand = useMemo(() => buildInstallCommand(namespace, slug, baseUrl, version), [baseUrl, namespace, slug, version])
|
||||
const skillhubCommand = useMemo(() => buildSkillhubInstallCommand(namespace, slug, baseUrl, version), [baseUrl, namespace, slug, version])
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="clawhub" className="space-y-3">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { startTransition, useEffect, useRef, useState } from 'react'
|
|||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { SkillSummary } from '@/api/types'
|
||||
import { COMPLIANCE_STANDARD_VALUES, type ComplianceStandard, type SkillSummary } from '@/api/types'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { SearchBar } from '@/features/search/search-bar'
|
||||
import { SkillCard } from '@/features/skill/skill-card'
|
||||
|
|
@ -17,7 +17,6 @@ import { Button } from '@/shared/ui/button'
|
|||
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
const COMPLIANCE_STANDARD_OPTIONS = ['mitre_attack', 'nist_csf', 'gdpr', 'hipaa', 'soc2'] as const
|
||||
|
||||
function blurActiveElement() {
|
||||
if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') {
|
||||
|
|
@ -94,7 +93,7 @@ export function SearchPage() {
|
|||
const q = normalizeSearchQuery(searchParams.q || '')
|
||||
const namespace = (searchParams.namespace || '').replace(/^@/, '')
|
||||
const selectedLabel = searchParams.label || ''
|
||||
const complianceStandard = searchParams.complianceStandard || ''
|
||||
const complianceStandard = searchParams.complianceStandard
|
||||
const sort = searchParams.sort || 'newest'
|
||||
const page = searchParams.page ?? 0
|
||||
const starredOnly = searchParams.starredOnly ?? false
|
||||
|
|
@ -105,7 +104,7 @@ export function SearchPage() {
|
|||
q: string
|
||||
namespace: string
|
||||
label: string
|
||||
complianceStandard: string
|
||||
complianceStandard?: ComplianceStandard
|
||||
sort: string
|
||||
page: number
|
||||
starredOnly: boolean
|
||||
|
|
@ -142,7 +141,7 @@ export function SearchPage() {
|
|||
q,
|
||||
namespace: namespace || undefined,
|
||||
label: selectedLabel || undefined,
|
||||
complianceStandard: complianceStandard || undefined,
|
||||
complianceStandard,
|
||||
sort,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
|
|
@ -228,8 +227,8 @@ export function SearchPage() {
|
|||
navigate({ to: '/search', search: buildSearchState({ label: nextLabel, page: 0 }) })
|
||||
}
|
||||
|
||||
const handleComplianceToggle = (standard: string) => {
|
||||
const nextStandard = complianceStandard === standard ? '' : standard
|
||||
const handleComplianceToggle = (standard: ComplianceStandard) => {
|
||||
const nextStandard = complianceStandard === standard ? undefined : standard
|
||||
navigate({ to: '/search', search: buildSearchState({ complianceStandard: nextStandard, page: 0 }) })
|
||||
}
|
||||
|
||||
|
|
@ -359,7 +358,7 @@ export function SearchPage() {
|
|||
{!starredOnly && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t('search.compliance.label')}</span>
|
||||
{COMPLIANCE_STANDARD_OPTIONS.map((standard) => (
|
||||
{COMPLIANCE_STANDARD_VALUES.map((standard) => (
|
||||
<Button
|
||||
key={standard}
|
||||
variant={complianceStandard === standard ? 'default' : 'outline'}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ vi.mock('@/features/skill/file-tree', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/features/skill/install-command', () => ({
|
||||
InstallCommand: () => <div>install</div>,
|
||||
InstallCommand: ({ version }: { version?: string }) => <div>install:{version ?? 'latest'}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/social/rating-input', () => ({
|
||||
|
|
@ -389,6 +389,7 @@ describe('SkillDetailPage', () => {
|
|||
expect(html).toContain('skillDetail.complianceSectionTitle')
|
||||
expect(html).toContain('Right to erasure')
|
||||
expect(html).toContain('skillDetail.complianceControlId')
|
||||
expect(html).toContain('install:0.9.0')
|
||||
})
|
||||
|
||||
it('shows the label management panel for a user who can manage the skill lifecycle', () => {
|
||||
|
|
|
|||
|
|
@ -207,6 +207,9 @@ export function SkillDetailPage() {
|
|||
const canManageLabels = Boolean(skill && user && (skill.canManageLifecycle || hasRole('SUPER_ADMIN')))
|
||||
const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false)
|
||||
const selectedVersionComplianceMappings = selectedVersionDetail?.complianceMappings ?? []
|
||||
const installCommandVersion = selectedVersionEntry?.status === 'PUBLISHED'
|
||||
? selectedVersionEntry.version
|
||||
: publishedVersion?.version
|
||||
|
||||
useEffect(() => {
|
||||
// Recompute collapse rules whenever rendered documentation height changes so the page can keep
|
||||
|
|
@ -1178,7 +1181,7 @@ export function SkillDetailPage() {
|
|||
<InstallCommand
|
||||
namespace={namespace}
|
||||
slug={slug}
|
||||
version={publishedVersion.version}
|
||||
version={installCommandVersion}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue