diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index cea241d8..0afc50b2 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -5,6 +5,8 @@ on: branches: [main] paths: - 'docs/skillhub/**' + - 'weekly/**' + - '.github/workflows/deploy-docs.yml' workflow_dispatch: permissions: @@ -34,8 +36,17 @@ jobs: uses: actions/configure-pages@v4 - name: Install dependencies run: cd docs/skillhub && npm ci + - name: Build and validate weekly reports + run: | + cd weekly + python3 scripts/build_site.py + python3 scripts/validate_site.py _site - name: Build with VitePress run: cd docs/skillhub && npm run build + - name: Add weekly reports to Pages artifact + run: | + mkdir -p docs/skillhub/.vitepress/dist/weekly + cp -R weekly/_site/. docs/skillhub/.vitepress/dist/weekly/ - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 56faed23..d8f96883 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -91,7 +91,9 @@ jobs: filters: | docs: - 'docs/skillhub/**' + - 'weekly/**' - '.github/workflows/pr-tests.yml' + - '.github/workflows/deploy-docs.yml' - name: Set up Node.js if: steps.changed.outputs.docs == 'true' @@ -108,3 +110,18 @@ jobs: - name: Build VitePress site if: steps.changed.outputs.docs == 'true' run: cd docs/skillhub && npm run build + + - name: Build and validate weekly reports + if: steps.changed.outputs.docs == 'true' + run: | + cd weekly + python3 scripts/build_site.py + python3 scripts/validate_site.py _site + + - name: Assemble Pages artifact layout + if: steps.changed.outputs.docs == 'true' + run: | + mkdir -p docs/skillhub/.vitepress/dist/weekly + cp -R weekly/_site/. docs/skillhub/.vitepress/dist/weekly/ + test -f docs/skillhub/.vitepress/dist/weekly/index.html + test -f docs/skillhub/.vitepress/dist/weekly/archive.html diff --git a/.gitignore b/.gitignore index 76a972db..a54e5768 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ package-lock.json .tmp/ tmp/ __pycache__/ +weekly/_site/ # Git worktrees .worktrees/ @@ -84,6 +85,9 @@ docs/superpowers/ # Local workspace metadata CLAUDE.md +# Local report-generation skill +.agents/skills/generate-skillhub-weekly-report/ + # Helm chart dependencies charts/skillhub/charts/*.tgz diff --git a/docs/skillhub/.vitepress/config.ts b/docs/skillhub/.vitepress/config.ts index cdacab91..001ff010 100644 --- a/docs/skillhub/.vitepress/config.ts +++ b/docs/skillhub/.vitepress/config.ts @@ -24,6 +24,7 @@ export default defineConfig({ { text: '首页', link: '/' }, { text: '快速开始', link: '/quickstart' }, { text: '功能指南', link: '/guide/skill-publish' }, + { text: '开源周报', link: 'https://iflytek.github.io/skillhub/weekly/' }, { text: 'FAQ', link: '/faq' }, ], sidebar: [ @@ -69,6 +70,7 @@ export default defineConfig({ { text: 'Home', link: '/en/' }, { text: 'Quick Start', link: '/en/quickstart' }, { text: 'Guide', link: '/en/guide/skill-publish' }, + { text: 'Weekly Reports', link: 'https://iflytek.github.io/skillhub/weekly/' }, { text: 'FAQ', link: '/en/faq' }, ], sidebar: [ diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index e7f86d45..3e2119d9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -98,7 +98,7 @@ public class SkillLifecycleAppService { Map userNamespaceRoles, AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); - SkillVersion skillVersion = findVersion(skill.getId(), version); + SkillVersion skillVersion = findVersionForUpdate(skill.getId(), version); skillGovernanceService.deleteVersion( skill, skillVersion, @@ -261,6 +261,13 @@ public class SkillLifecycleAppService { .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); } + private SkillVersion findVersionForUpdate(Long skillId, String version) { + return skillVersionRepository.findBySkillIdForUpdate(skillId).stream() + .filter(candidate -> candidate.getVersion().equals(version)) + .findFirst() + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version)); + } + private Map normalizeRoles(Map userNamespaceRoles) { return userNamespaceRoles != null ? userNamespaceRoles : Map.of(); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index 09751065..bef8c274 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -144,7 +144,8 @@ class SkillLifecycleControllerTest { given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) .willReturn(skill); - given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version)); + given(skillVersionRepository.findBySkillIdForUpdate(1L)) + .willReturn(java.util.List.of(version)); mockMvc.perform(delete("/api/web/skills/global/demo-skill/versions/1.0.0") .requestAttr("userId", "usr_1") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java new file mode 100644 index 00000000..25d585e5 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java @@ -0,0 +1,144 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +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.storage.ObjectStorageService; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillVersionDeleteFlowIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private NamespaceRepository namespaceRepository; + + @Autowired + private SkillRepository skillRepository; + + @Autowired + private SkillVersionRepository skillVersionRepository; + + @Autowired + private ReviewTaskRepository reviewTaskRepository; + + @MockBean + private ObjectStorageService objectStorageService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void deleteRejectedVersion_removesOnlyItsReviewHistory() throws Exception { + String ownerId = "owner-1"; + String suffix = UUID.randomUUID().toString().substring(0, 8); + Namespace namespace = namespaceRepository.save( + new Namespace("version-delete-" + suffix, "Version Delete " + suffix, ownerId) + ); + + Skill skill = new Skill(namespace.getId(), "demo-skill-" + suffix, ownerId, SkillVisibility.PUBLIC); + skill.setCreatedBy(ownerId); + skill.setUpdatedBy(ownerId); + skill = skillRepository.save(skill); + + SkillVersion rejectedVersion = new SkillVersion(skill.getId(), "1.0.0", ownerId); + rejectedVersion.setStatus(SkillVersionStatus.REJECTED); + rejectedVersion = skillVersionRepository.save(rejectedVersion); + + SkillVersion retainedVersion = new SkillVersion(skill.getId(), "2.0.0", ownerId); + retainedVersion.setStatus(SkillVersionStatus.REJECTED); + retainedVersion = skillVersionRepository.save(retainedVersion); + + ReviewTask rejectedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId); + rejectedTask.setStatus(ReviewTaskStatus.REJECTED); + rejectedTask = reviewTaskRepository.save(rejectedTask); + + ReviewTask approvedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId); + approvedTask.setStatus(ReviewTaskStatus.APPROVED); + approvedTask = reviewTaskRepository.save(approvedTask); + + ReviewTask retainedTask = new ReviewTask(retainedVersion.getId(), namespace.getId(), ownerId); + retainedTask.setStatus(ReviewTaskStatus.REJECTED); + retainedTask = reviewTaskRepository.save(retainedTask); + + Long skillId = skill.getId(); + Long rejectedVersionId = rejectedVersion.getId(); + + mockMvc.perform(delete("/api/web/skills/{namespace}/{slug}/versions/{version}", + namespace.getSlug(), skill.getSlug(), rejectedVersion.getVersion()) + .with(authentication(portalAuth(ownerId, "USER"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(skillId)) + .andExpect(jsonPath("$.data.versionId").value(rejectedVersionId)) + .andExpect(jsonPath("$.data.action").value("DELETE_VERSION")) + .andExpect(jsonPath("$.data.status").value("1.0.0")); + + assertThat(skillVersionRepository.findById(rejectedVersion.getId())).isEmpty(); + assertThat(skillVersionRepository.findById(retainedVersion.getId())).isPresent(); + assertThat(reviewTaskRepository.findById(rejectedTask.getId())).isEmpty(); + assertThat(reviewTaskRepository.findById(approvedTask.getId())).isEmpty(); + assertThat(reviewTaskRepository.findById(retainedTask.getId())).isPresent(); + verify(objectStorageService).deleteObjects(argThat(keys -> + keys.equals(List.of("packages/" + skillId + "/" + rejectedVersionId + "/bundle.zip")) + )); + } + + private UsernamePasswordAuthenticationToken portalAuth(String userId, String... roles) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + Set.of(roles) + ); + List authorities = Arrays.stream(roles) + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + return new UsernamePasswordAuthenticationToken(principal, null, authorities); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java index e2c10146..b007621f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java @@ -14,7 +14,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.review.ReviewService; import com.iflytek.skillhub.domain.skill.Skill; +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.service.SkillGovernanceService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; @@ -76,4 +78,50 @@ class SkillLifecycleAppServiceTest { assertThat(response.status()).isEqualTo("ARCHIVED"); verify(skillGovernanceService).archiveSkill(11L, "owner-1", Map.of(7L, NamespaceRole.OWNER), "127.0.0.1", "JUnit", "cleanup"); } + + @Test + void deleteVersion_locksAllSkillVersionsBeforeDelegatingLifecycleMutation() { + Namespace namespace = new Namespace("global", "Global", "owner-1"); + ReflectionTestUtils.setField(namespace, "id", 7L); + Skill skill = new Skill(7L, "demo-skill", "owner-1", SkillVisibility.PUBLIC); + ReflectionTestUtils.setField(skill, "id", 11L); + SkillVersion version = new SkillVersion(11L, "1.0.0", "owner-1"); + ReflectionTestUtils.setField(version, "id", 13L); + version.setStatus(SkillVersionStatus.REJECTED); + SkillVersion retainedVersion = new SkillVersion(11L, "2.0.0", "owner-1"); + ReflectionTestUtils.setField(retainedVersion, "id", 14L); + retainedVersion.setStatus(SkillVersionStatus.UPLOADED); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(skillSlugResolutionService.resolve( + 7L, + "demo-skill", + "owner-1", + SkillSlugResolutionService.Preference.CURRENT_USER + )).thenReturn(skill); + when(skillVersionRepository.findBySkillIdForUpdate(11L)) + .thenReturn(java.util.List.of(version, retainedVersion)); + + var response = service.deleteVersion( + "global", + "demo-skill", + "1.0.0", + "owner-1", + Map.of(7L, NamespaceRole.OWNER), + new AuditRequestContext("127.0.0.1", "JUnit") + ); + + assertThat(response.versionId()).isEqualTo(13L); + assertThat(response.action()).isEqualTo("DELETE_VERSION"); + verify(skillVersionRepository).findBySkillIdForUpdate(11L); + verify(skillGovernanceService).deleteVersion( + skill, + version, + "owner-1", + Map.of(7L, NamespaceRole.OWNER), + "127.0.0.1", + "JUnit", + "global" + ); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java index 3175c83f..293a3dd7 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java @@ -249,6 +249,11 @@ class ScanTaskConsumerLoggingTest { throw new UnsupportedOperationException(); } + @Override + public List findBySkillIdForUpdate(Long skillId) { + throw new UnsupportedOperationException(); + } + @Override public List findBySkillIdAndStatus(Long skillId, SkillVersionStatus status) { throw new UnsupportedOperationException(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index 8e9a8ff6..c8c55f0f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -415,6 +415,11 @@ class ScanTaskConsumerTest { throw unsupported(); } + @Override + public List findBySkillIdForUpdate(Long skillId) { + throw unsupported(); + } + @Override public List findBySkillIdAndStatus(Long skillId, SkillVersionStatus status) { throw unsupported(); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index 6565436f..b99cc50f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -12,6 +12,7 @@ public interface SkillVersionRepository { List findBySkillIdIn(List skillIds); List findBySkillIdInAndStatus(List skillIds, SkillVersionStatus status); List findBySkillId(Long skillId); + List findBySkillIdForUpdate(Long skillId); Optional findBySkillIdAndVersion(Long skillId, String version); List findBySkillIdAndStatus(Long skillId, SkillVersionStatus status); SkillVersion save(SkillVersion version); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index bbb748f8..3cad927f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.security.SecurityScanService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; @@ -41,6 +42,7 @@ public class SkillGovernanceService { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillFileRepository skillFileRepository; + private final ReviewTaskRepository reviewTaskRepository; private final ObjectStorageService objectStorageService; private final AuditLogService auditLogService; private final ApplicationEventPublisher eventPublisher; @@ -51,6 +53,7 @@ public class SkillGovernanceService { public SkillGovernanceService(SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillFileRepository skillFileRepository, + ReviewTaskRepository reviewTaskRepository, ObjectStorageService objectStorageService, AuditLogService auditLogService, ApplicationEventPublisher eventPublisher, @@ -60,6 +63,7 @@ public class SkillGovernanceService { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillFileRepository = skillFileRepository; + this.reviewTaskRepository = reviewTaskRepository; this.objectStorageService = objectStorageService; this.auditLogService = auditLogService; this.eventPublisher = eventPublisher; @@ -172,6 +176,8 @@ public class SkillGovernanceService { throw new DomainBadRequestException("error.skill.version.delete.lastVersion", version.getVersion()); } + // Rejected versions retain terminal review history whose FK must not outlive the version. + reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId())); List files = skillFileRepository.findByVersionId(version.getId()); List storageKeys = new ArrayList<>(); files.stream() diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index b6f3faff..50aa3e4b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -5,40 +5,43 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.doThrow; -import static org.mockito.BDDMockito.given; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.security.SecurityScanService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.SkillFileRepository; -import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; 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.storage.ObjectStorageService; import java.time.Clock; import java.time.Instant; -import java.util.Optional; -import java.util.Map; import java.time.ZoneOffset; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; +import java.util.Map; +import java.util.Optional; 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.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @ExtendWith(MockitoExtension.class) class SkillGovernanceServiceTest { @@ -52,6 +55,8 @@ class SkillGovernanceServiceTest { @Mock private SkillFileRepository skillFileRepository; @Mock + private ReviewTaskRepository reviewTaskRepository; + @Mock private ObjectStorageService objectStorageService; @Mock private AuditLogService auditLogService; @@ -70,6 +75,7 @@ class SkillGovernanceServiceTest { skillRepository, skillVersionRepository, skillFileRepository, + reviewTaskRepository, objectStorageService, auditLogService, eventPublisher, @@ -229,6 +235,35 @@ class SkillGovernanceServiceTest { verify(auditLogService).record("owner", "DELETE_SKILL_VERSION", "SKILL_VERSION", 2L, null, "127.0.0.1", "JUnit", "{\"version\":\"1.0.0\"}"); } + @Test + void deleteVersion_removesReviewTasksBeforeRejectedVersion() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 1L); + SkillVersion rejectedVersion = new SkillVersion(1L, "1.0.0", "owner"); + setField(rejectedVersion, "id", 2L); + rejectedVersion.setStatus(SkillVersionStatus.REJECTED); + SkillVersion otherVersion = new SkillVersion(1L, "2.0.0", "owner"); + setField(otherVersion, "id", 3L); + otherVersion.setStatus(SkillVersionStatus.DRAFT); + given(skillVersionRepository.findBySkillId(1L)) + .willReturn(java.util.List.of(rejectedVersion, otherVersion)); + given(skillFileRepository.findByVersionId(2L)).willReturn(java.util.List.of()); + + service.deleteVersion( + skill, + rejectedVersion, + "owner", + Map.of(), + "127.0.0.1", + "JUnit", + "test-ns" + ); + + InOrder deletionOrder = inOrder(reviewTaskRepository, skillVersionRepository); + deletionOrder.verify(reviewTaskRepository).deleteBySkillVersionIdIn(java.util.List.of(2L)); + deletionOrder.verify(skillVersionRepository).delete(rejectedVersion); + } + @Test void deleteVersion_deletesStorageAfterCommitWhenSynchronizationIsActive() { Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); @@ -309,10 +344,36 @@ class SkillGovernanceServiceTest { assertThrows(DomainBadRequestException.class, () -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns")); + verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(anyList()); verify(skillVersionRepository, never()).delete(any()); verify(objectStorageService, never()).deleteObject(any()); } + @Test + void deleteVersion_rejectsUnauthorizedUserWithoutDeletingReviewTasks() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + setField(skill, "id", 1L); + SkillVersion version = new SkillVersion(1L, "1.0.0", "owner"); + setField(version, "id", 2L); + version.setStatus(SkillVersionStatus.REJECTED); + + assertThrows( + DomainForbiddenException.class, + () -> service.deleteVersion( + skill, + version, + "member", + Map.of(1L, NamespaceRole.MEMBER), + "127.0.0.1", + "JUnit", + "test-ns" + ) + ); + + verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(anyList()); + verify(skillVersionRepository, never()).delete(any()); + } + @Test void deleteVersion_rejectsLastRemainingVersion() { Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); @@ -326,6 +387,7 @@ class SkillGovernanceServiceTest { () -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns")); assertThat(ex.messageCode()).isEqualTo("error.skill.version.delete.lastVersion"); + verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(anyList()); verify(skillVersionRepository, never()).delete(any()); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java index eabb01e4..e7c85e1c 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java @@ -3,16 +3,22 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.util.List; -import java.util.Optional; - /** * JPA-backed repository for skill version history and status-oriented version queries. + * + *

The deletion lock uses explicit {@code FOR UPDATE} SQL because Hibernate's PostgreSQL dialect + * emits {@code FOR NO KEY UPDATE}, which H2's PostgreSQL compatibility mode cannot execute. It + * locks every version in stable ID order so concurrent deletions cannot both remove the last + * versions of one skill. */ @Repository public interface SkillVersionJpaRepository extends JpaRepository, SkillVersionRepository { @@ -22,6 +28,16 @@ public interface SkillVersionJpaRepository extends JpaRepository findBySkillIdInAndStatusOrderByCreatedAtDesc(List skillIds, SkillVersionStatus status); Optional findBySkillIdAndVersion(Long skillId, String version); + @Override + @Query(value = """ + SELECT skill_version.* + FROM skill_version + WHERE skill_version.skill_id = :skillId + ORDER BY skill_version.id + FOR UPDATE + """, nativeQuery = true) + List findBySkillIdForUpdate(@Param("skillId") Long skillId); + @Override default List findBySkillIdAndStatus(Long skillId, SkillVersionStatus status) { return findBySkillIdAndStatusOrderByCreatedAtDesc(skillId, status); diff --git a/weekly/README.md b/weekly/README.md new file mode 100644 index 00000000..0d9cfe99 --- /dev/null +++ b/weekly/README.md @@ -0,0 +1,26 @@ +# SkillHub Weekly Mirror + +This directory is the reviewed mirror of the public +[`XiaoSeS/skillhub-weekly`](https://github.com/XiaoSeS/skillhub-weekly) site. +The standalone repository remains the authoritative content source. + +The SkillHub documentation workflow builds this directory and places the result +under the existing VitePress Pages artifact: + +- Latest report: `https://iflytek.github.io/skillhub/weekly/` +- Archive: `https://iflytek.github.io/skillhub/weekly/archive.html` +- Report: `https://iflytek.github.io/skillhub/weekly/reports//` + +## Local validation + +```bash +cd weekly +python3 scripts/sync_report_theme.py site/reports/*/index.html +python3 scripts/build_site.py +python3 scripts/validate_site.py _site +``` + +Do not edit `_site/`; it is ignored and rebuilt by CI. Update reports in the +standalone repository first, then copy `site/`, `assets/`, and `scripts/` +byte-for-byte into this directory so both published sites keep the same report +HTML, Notion-light theme, charts, and Tab behavior. diff --git a/weekly/assets/notion-light.css b/weekly/assets/notion-light.css new file mode 100644 index 00000000..7d8aa8ac --- /dev/null +++ b/weekly/assets/notion-light.css @@ -0,0 +1,1176 @@ +:root { + color-scheme: light; + --page: #ffffff; + --paper: #ffffff; + --warm: #f6f5f4; + --ink: #0d0d0d; + --ink-soft: #31302e; + --muted: #615d59; + --faint: #76716c; + --line: #e5e3e1; + --line-soft: #efeeec; + --blue: #0075de; + --blue-active: #005bab; + --blue-soft: #f2f9ff; + --green: #147a33; + --green-soft: #e9f7ec; + --orange: #b84d00; + --orange-soft: #fdf0e3; + --red: #b52d25; + --red-soft: #fdecea; + --neutral-soft: #f1f0ef; + --focus: #097fe8; + --radius-sm: 8px; + --radius: 12px; + --shadow: 0 2px 8px rgb(0 0 0 / 4%); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--page); + color: var(--ink); + font: 15px/1.65 Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--blue); + text-decoration: none; + text-underline-offset: 3px; +} + +a:hover { + color: var(--blue-active); + text-decoration: underline; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 3px; +} + +code { + padding: 1px 4px; + border-radius: 4px; + background: var(--neutral-soft); + color: var(--ink-soft); + font-size: .92em; +} + +.skip-link { + position: fixed; + z-index: 30; + top: 8px; + left: -999px; + padding: 8px 12px; + border-radius: 4px; + background: var(--ink-soft); + color: #fff; +} + +.skip-link:focus { + left: 8px; +} + +.report { + width: min(1200px, 100%); + min-height: 100vh; + margin: 0 auto; + background: var(--paper); +} + +.masthead { + padding: 44px 28px 36px; + border-bottom: 1px solid var(--line); +} + +.topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 26px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + color: var(--ink); +} + +.brand-mark { + display: inline-flex; + width: 34px; + height: 34px; + align-items: center; + justify-content: center; + border-radius: 6px; + background: var(--ink-soft); + color: #fff; + font-size: 14px; + font-weight: 700; + letter-spacing: .04em; +} + +.brand-name { + font-size: 17px; + font-weight: 700; + letter-spacing: -.01em; +} + +.brand-tag { + padding: 4px 10px; + border-radius: 9999px; + background: var(--neutral-soft); + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.utility-links { + display: flex; + flex-wrap: wrap; + gap: 16px; + font-size: 13px; +} + +h1, +h2, +h3 { + text-wrap: balance; +} + +h1 { + max-width: 820px; + margin: 0; + color: var(--ink); + font-size: clamp(32px, 4vw, 44px); + line-height: 1.15; + letter-spacing: -.025em; +} + +.report-sub { + margin: 8px 0 24px; + color: var(--muted); + font-size: 15px; +} + +.header-grid { + display: grid; + grid-template-columns: 1.15fr .85fr; + gap: 16px; +} + +.meta-card, +.headline { + border-radius: var(--radius-sm); + padding: 20px 22px; +} + +.meta-card { + background: var(--warm); +} + +.meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px 24px; +} + +.meta-item .key, +.headline-label { + display: block; + margin-bottom: 4px; + color: var(--faint); + font-size: 12px; + font-weight: 500; + letter-spacing: .01em; +} + +.meta-item .value { + color: var(--ink-soft); + font-size: 14px; + font-weight: 600; +} + +.period { + margin: 0; +} + +.headline { + display: flex; + flex-direction: column; + justify-content: center; + margin: 0; + border: 0; + background: var(--orange-soft); + color: var(--ink-soft); +} + +.headline-status { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 7px; +} + +.headline-status strong { + color: var(--orange); + font-size: 22px; + line-height: 1.2; +} + +.headline p { + margin: 0; + max-width: 60ch; +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-top: 18px; + border: 0; +} + +.metric { + min-width: 0; + padding: 16px; + border: 0; + border-radius: var(--radius-sm); + background: var(--warm); +} + +.metric strong { + display: block; + color: var(--ink); + font-size: 26px; + line-height: 1.15; + letter-spacing: -.02em; + font-variant-numeric: tabular-nums; +} + +.metric span { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 12.5px; +} + +.tabs-wrap { + position: sticky; + z-index: 20; + top: 0; + overflow-x: auto; + border-bottom: 1px solid var(--line); + background: rgb(255 255 255 / 97%); + backdrop-filter: saturate(1.1) blur(5px); +} + +.tabs { + display: flex; + min-width: max-content; + padding: 0 28px; +} + +.tab { + position: relative; + min-height: 48px; + padding: 0 14px; + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 500; +} + +.tab::after { + position: absolute; + right: 14px; + bottom: -1px; + left: 14px; + height: 2px; + background: transparent; + content: ""; +} + +.tab:hover, +.tab[aria-selected="true"] { + color: var(--ink); +} + +.tab[aria-selected="true"] { + font-weight: 600; +} + +.tab[aria-selected="true"]::after { + background: var(--ink-soft); +} + +main { + padding: 0 28px 48px; +} + +.tab-panel { + padding-top: 4px; +} + +.js .tab-panel[hidden] { + display: none; +} + +.panel-intro { + display: flex; + justify-content: space-between; + gap: 24px; + margin: 30px 0 0; + color: var(--muted); + font-size: 13px; +} + +.panel-intro p { + margin: 0; +} + +section.report-section { + margin: 0; + padding: 48px 0; + border: 0; +} + +.tab-panel > section.report-section:nth-of-type(even) { + background: var(--warm); + box-shadow: 0 0 0 100vmax var(--warm); + clip-path: inset(0 -100vmax); +} + +section.report-section:first-of-type { + padding-top: 42px; +} + +h2 { + margin: 0 0 18px; + color: var(--ink); + font-size: 26px; + line-height: 1.25; + letter-spacing: -.02em; +} + +h3 { + margin: 30px 0 12px; + color: var(--ink); + font-size: 18px; + line-height: 1.35; + letter-spacing: -.01em; +} + +p { + max-width: 75ch; + text-wrap: pretty; +} + +.section-lead { + margin: -7px 0 18px; + color: var(--muted); +} + +.table-wrap { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--paper); +} + +table { + width: 100%; + min-width: 680px; + border-collapse: collapse; + font-size: 13.5px; +} + +caption { + padding: 10px 14px; + background: var(--warm); + color: var(--ink-soft); + font-weight: 600; + text-align: left; +} + +th, +td { + padding: 10px 14px; + border-bottom: 1px solid var(--line-soft); + vertical-align: top; + text-align: left; +} + +th { + background: var(--warm); + color: var(--muted); + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; +} + +tbody tr:last-child td { + border-bottom: 0; +} + +tbody tr:hover { + background: #faf9f8; +} + +.number { + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.status, +.value-level { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 25px; + padding: 4px 9px; + border: 0; + border-radius: 9999px; + font-size: 12px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +.status::before { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + content: ""; +} + +.status.ok { + background: var(--green-soft); + color: var(--green); +} + +.status.warn { + background: var(--orange-soft); + color: var(--orange); +} + +.status.risk { + background: var(--red-soft); + color: var(--red); +} + +.value-a { + background: var(--red-soft); + color: var(--red); +} + +.value-b { + background: var(--orange-soft); + color: var(--orange); +} + +.value-c { + background: var(--blue-soft); + color: var(--blue-active); +} + +.value-d { + background: var(--neutral-soft); + color: var(--muted); +} + +.risk-note { + margin: 16px 0 0; + padding: 14px 18px; + border: 0; + border-radius: var(--radius-sm); + background: var(--orange-soft); + color: var(--ink-soft); +} + +.repo-visuals { + display: grid; + grid-template-columns: minmax(280px, .85fr) minmax(0, 1.15fr); + gap: 18px; +} + +.snapshot-list { + display: grid; + gap: 0; +} + +.snapshot-row { + display: grid; + grid-template-columns: minmax(90px, 1fr) auto; + gap: 4px 16px; + align-items: center; + padding: 13px 0; + border-bottom: 1px solid var(--line-soft); +} + +.snapshot-row:first-child { + padding-top: 0; +} + +.snapshot-row:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.snapshot-row .snapshot-label { + color: var(--muted); + font-size: 13px; +} + +.snapshot-row strong { + color: var(--ink); + font-size: 28px; + line-height: 1.05; + letter-spacing: -.02em; + font-variant-numeric: tabular-nums; +} + +.snapshot-row .snapshot-change { + grid-column: 1 / -1; + color: var(--muted); + font-size: 12.5px; +} + +.snapshot-row .snapshot-change.positive { + color: var(--green); +} + +.snapshot-row .snapshot-change.missing { + color: var(--orange); +} + +.chart-note { + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px dashed var(--line); + color: var(--muted); + font-size: 12.5px; +} + +.ecosystem-signals { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.ecosystem-signal { + padding: 16px 18px; + border-radius: var(--radius-sm); + background: var(--paper); + box-shadow: inset 0 0 0 1px var(--line); +} + +.ecosystem-signal strong { + display: block; + color: var(--ink); + font-size: 24px; + line-height: 1.15; + letter-spacing: -.02em; + font-variant-numeric: tabular-nums; +} + +.ecosystem-signal span { + display: block; + margin-top: 5px; + color: var(--muted); + font-size: 12.5px; +} + +.ecosystem-signal-note { + grid-column: 1 / -1; + margin: -3px 0 0; + color: var(--faint); + font-size: 12px; +} + +.ecosystem-list, +.method-list { + margin: 0; + padding: 0; + border-top: 1px solid var(--line); + list-style: none; +} + +.ecosystem-list li, +.method-list li { + display: grid; + grid-template-columns: 150px 1fr; + gap: 20px; + padding: 13px 0; + border-bottom: 1px solid var(--line); +} + +.method-list li { + grid-template-columns: 180px 1fr; +} + +.ecosystem-list strong, +.method-list strong { + color: var(--ink); +} + +.next-actions { + margin: 4px 0 30px; + padding: 20px 22px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--paper); +} + +.next-actions h2 { + margin-bottom: 8px; + font-size: 18px; +} + +.next-actions ol { + margin: 0; + padding-left: 22px; +} + +.next-actions li { + margin: 7px 0; +} + +.health-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.health-card, +.chart-box, +.health-note { + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--paper); + box-shadow: var(--shadow); +} + +.health-card { + display: flex; + flex-direction: column; + gap: 9px; + padding: 20px 22px; +} + +.health-card .top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.health-card .name { + color: var(--ink); + font-size: 15px; + font-weight: 700; +} + +.health-card .health-metric { + color: var(--ink); + font-size: 27px; + font-weight: 700; + line-height: 1.1; + letter-spacing: -.02em; + font-variant-numeric: tabular-nums; +} + +.health-card .health-metric small { + color: var(--muted); + font-size: 13px; + font-weight: 500; + letter-spacing: 0; +} + +.health-card p { + margin: 0; + color: var(--ink-soft); + font-size: 13.5px; +} + +.health-card .detail { + padding-top: 9px; + border-top: 1px dashed var(--line); + color: var(--muted); + font-size: 12.5px; +} + +.segbar { + display: flex; + height: 24px; + overflow: hidden; + margin: 15px 0 10px; + border-radius: 6px; + background: var(--neutral-soft); +} + +.segbar > span { + min-width: 2px; + height: 100%; +} + +.seg-success { + background: #1aae39; +} + +.seg-failure { + background: #e16259; +} + +.seg-auth { + background: #e8a94b; +} + +.seg-skipped { + background: #d6d2cd; +} + +.seg-cancelled { + background: #8e8984; +} + +.chart-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + color: var(--muted); + font-size: 13px; +} + +.chart-legend span { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.chart-legend i { + width: 10px; + height: 10px; + border-radius: 3px; +} + +.chart-legend b { + color: var(--ink-soft); + font-variant-numeric: tabular-nums; +} + +.chart-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.chart-box { + padding: 20px 22px; +} + +.chart-box h3 { + margin: 0 0 14px; + font-size: 15px; +} + +.bars { + display: grid; + gap: 11px; +} + +.bar-row { + display: grid; + grid-template-columns: 88px 1fr 42px; + gap: 10px; + align-items: center; + font-size: 13px; +} + +.repo-visuals .bar-row { + grid-template-columns: 112px 1fr 36px; +} + +.bar-label { + color: var(--muted); + white-space: nowrap; +} + +.bar-track { + height: 16px; + overflow: hidden; + border-radius: 5px; + background: var(--neutral-soft); +} + +.bar-fill { + display: block; + height: 100%; + min-width: 2px; + border-radius: 5px; + background: var(--blue); +} + +.bar-fill.hot { + background: var(--orange); +} + +.bar-value { + color: var(--ink-soft); + font-weight: 700; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.donut-layout { + display: grid; + grid-template-columns: 150px 1fr; + gap: 22px; + align-items: center; +} + +.donut { + position: relative; + width: 142px; + height: 142px; + border-radius: 50%; + background: conic-gradient(var(--orange) 0 var(--value), #d6d2cd var(--value) 100%); +} + +.donut::after { + position: absolute; + inset: 24px; + border-radius: 50%; + background: var(--paper); + content: ""; +} + +.donut-center { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.donut-center strong { + color: var(--ink); + font-size: 26px; + line-height: 1.1; +} + +.donut-center span { + color: var(--muted); + font-size: 12px; +} + +.donut-copy p { + margin: 0 0 9px; + color: var(--ink-soft); + font-size: 13.5px; +} + +.donut-copy p:last-child { + margin-bottom: 0; +} + +.health-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-top: 18px; +} + +.chart-box + .table-wrap { + margin-top: 18px; +} + +.health-note { + padding: 18px; +} + +.health-note h3 { + margin: 0 0 7px; + font-size: 15px; +} + +.health-note p { + margin: 0; + color: var(--ink-soft); +} + +.flow-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 18px 0 24px; +} + +.flow-item { + padding: 16px 18px; + border-radius: var(--radius-sm); + background: var(--warm); +} + +.flow-item strong { + display: block; + color: var(--ink); + font-size: 20px; + font-variant-numeric: tabular-nums; +} + +.flow-item span { + color: var(--muted); + font-size: 12.5px; +} + +details { + margin-top: 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--paper); +} + +summary { + padding: 14px 18px; + color: var(--ink-soft); + cursor: pointer; + font-weight: 700; +} + +.details-body { + padding: 0 18px 16px; +} + +footer { + display: flex; + justify-content: space-between; + gap: 24px; + padding: 22px 28px 28px; + border-top: 1px solid var(--line); + background: var(--warm); + color: var(--muted); + font-size: 12px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 760px) { + .masthead, + main, + footer { + padding-right: 18px; + padding-left: 18px; + } + + .topline, + footer, + .panel-intro { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .header-grid, + .meta-grid, + .repo-visuals, + .chart-grid, + .health-grid, + .flow-summary { + grid-template-columns: 1fr; + } + + .header-grid > *, + .meta-item { + min-width: 0; + } + + .meta-item .value { + overflow-wrap: anywhere; + } + + .metrics { + grid-template-columns: repeat(2, 1fr); + } + + .tabs { + padding: 0 6px; + } + + section.report-section { + padding: 38px 0; + } + + .ecosystem-list li, + .method-list li { + grid-template-columns: 1fr; + gap: 3px; + } + + .ecosystem-signals { + grid-template-columns: 1fr; + } + + .ecosystem-signal-note { + grid-column: auto; + } + + .donut-layout { + grid-template-columns: 1fr; + justify-items: center; + } + + .donut-copy { + text-align: center; + } +} + +@media (max-width: 430px) { + h1 { + font-size: 30px; + overflow-wrap: anywhere; + } + + .bar-row { + grid-template-columns: 76px 1fr 32px; + gap: 7px; + } + + .repo-visuals .bar-row { + grid-template-columns: 98px 1fr 28px; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + +@media print { + @page { + size: A4; + margin: 13mm; + } + + body { + background: #fff; + font-size: 10px; + } + + .report { + width: 100%; + } + + .masthead, + main, + footer { + padding-right: 0; + padding-left: 0; + } + + .tabs-wrap, + .skip-link, + .utility-links { + display: none !important; + } + + .tab-panel[hidden] { + display: block !important; + } + + .tab-panel { + break-before: page; + } + + .tab-panel:first-child { + break-before: auto; + } + + .tab-panel > section.report-section:nth-of-type(even) { + background: #fff; + box-shadow: none; + clip-path: none; + } + + section.report-section { + padding: 16px 0; + } + + h1 { + font-size: 27px; + } + + h2, + h3, + tr, + .health-card, + .health-note, + .chart-box, + .next-actions { + break-inside: avoid; + } + + table { + min-width: 0; + font-size: 8px; + } + + th, + td { + padding: 5px 6px; + } + + .health-card, + .health-note, + .chart-box { + box-shadow: none; + } + + .segbar, + .bar-fill, + .donut, + .status, + .value-level, + .metric, + .headline { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + } +} diff --git a/weekly/scripts/build_site.py b/weekly/scripts/build_site.py new file mode 100755 index 00000000..f26a6467 --- /dev/null +++ b/weekly/scripts/build_site.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Build the SkillHub weekly report site from self-contained report files.""" + +from __future__ import annotations + +import argparse +import json +import shutil +from html import escape +from pathlib import Path + + +def load_manifest(source: Path) -> tuple[str, list[dict[str, str]]]: + manifest_path = source / "reports.json" + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + latest = payload.get("latest") + reports = payload.get("reports") + if not isinstance(latest, str) or not latest: + raise ValueError("reports.json must define a non-empty latest week") + if not isinstance(reports, list) or not reports: + raise ValueError("reports.json must contain at least one report") + + required = {"week", "title", "period", "snapshot", "path"} + normalized: list[dict[str, str]] = [] + for index, report in enumerate(reports): + if not isinstance(report, dict) or not required.issubset(report): + missing = required - set(report) if isinstance(report, dict) else required + raise ValueError(f"report #{index + 1} is missing fields: {sorted(missing)}") + normalized.append({key: str(report[key]) for key in required}) + + weeks = {report["week"] for report in normalized} + if latest not in weeks: + raise ValueError(f"latest week {latest!r} is not present in reports") + return latest, sorted(normalized, key=lambda item: item["week"], reverse=True) + + +def render_archive(latest: str, reports: list[dict[str, str]]) -> str: + rows = "\n".join( + f"""

  • + + + + {escape(report['week'])} + {'最新' if report['week'] == latest else ''} + + {escape(report['title'])} + {escape(report['period'])} · 快照 {escape(report['snapshot'])} + + + +
  • """ + for report in reports + ) + return f""" + + + + + SkillHub 开源周报归档 + + + +
    +
    +
    + + SkillHub + 开源周报 +
    + +
    +

    SkillHub 开源周报归档

    +

    按统计周期倒序查看历期开源周报。

    +
    当前共收录 {len(reports)} 期,最新一期为 {escape(latest)}
    +
      +{rows} +
    + 返回最新周报 +
    + + +""" + + +def build(source: Path, output: Path) -> None: + latest, reports = load_manifest(source) + latest_report = next(report for report in reports if report["week"] == latest) + latest_source = source / latest_report["path"] / "index.html" + if not latest_source.is_file(): + raise FileNotFoundError(f"latest report not found: {latest_source}") + + for report in reports: + report_file = source / report["path"] / "index.html" + if not report_file.is_file(): + raise FileNotFoundError(f"report not found: {report_file}") + + if output.exists(): + shutil.rmtree(output) + shutil.copytree(source, output) + + latest_html = latest_source.read_text(encoding="utf-8") + latest_html = latest_html.replace('href="../../archive.html"', 'href="./archive.html"') + (output / "index.html").write_text(latest_html, encoding="utf-8") + (output / "archive.html").write_text( + render_archive(latest, reports), + encoding="utf-8", + ) + (output / ".nojekyll").write_text("", encoding="utf-8") + print(f"Built {len(reports)} report(s); latest={latest}; output={output}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=Path("site")) + parser.add_argument("--output", type=Path, default=Path("_site")) + args = parser.parse_args() + build(args.source.resolve(), args.output.resolve()) + + +if __name__ == "__main__": + main() diff --git a/weekly/scripts/sync_report_theme.py b/weekly/scripts/sync_report_theme.py new file mode 100755 index 00000000..9873393a --- /dev/null +++ b/weekly/scripts/sync_report_theme.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Inline the canonical Notion-light theme into self-contained weekly reports.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + + +THEME_PATTERN = re.compile( + r'(?P)", + flags=re.DOTALL, +) + + +def sync_theme(theme_path: Path, report_paths: list[Path]) -> None: + theme = theme_path.read_text(encoding="utf-8").rstrip() + for report_path in report_paths: + source = report_path.read_text(encoding="utf-8") + updated, replacements = THEME_PATTERN.subn( + lambda match: f"{match.group('open')}{theme}{match.group('close')}", + source, + ) + if replacements != 1: + raise ValueError( + f"{report_path}: expected one notion-light theme block, " + f"found {replacements}" + ) + report_path.write_text(updated, encoding="utf-8") + print(f"Synced theme: {report_path}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "reports", + type=Path, + nargs="+", + help="HTML report files containing a notion-light theme block", + ) + parser.add_argument( + "--theme", + type=Path, + default=Path("assets/notion-light.css"), + help="canonical CSS file", + ) + args = parser.parse_args() + sync_theme(args.theme.resolve(), [path.resolve() for path in args.reports]) + + +if __name__ == "__main__": + main() diff --git a/weekly/scripts/validate_site.py b/weekly/scripts/validate_site.py new file mode 100755 index 00000000..64e4d1df --- /dev/null +++ b/weekly/scripts/validate_site.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Validate built routes and basic accessibility hooks for the weekly site.""" + +from __future__ import annotations + +import argparse +import json +import sys +from html.parser import HTMLParser +from pathlib import Path + + +VOID_ELEMENTS = { + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +} +NON_CONTENT_ELEMENTS = {"caption", "h1", "h2", "h3", "h4", "h5", "h6", "th"} +ALLOWED_PANELS = {"panel-overview", "panel-health", "panel-flow", "panel-method"} + + +class PageParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.h1_count = 0 + self.tabs = 0 + self.panels = 0 + self.tab_controls: set[str] = set() + self.panel_ids: set[str] = set() + self.panel_modules: dict[str, int] = {} + self.current_panel: str | None = None + self.panel_depth = 0 + self.module_stack: list[dict[str, object]] = [] + self.module_counts: dict[str, int] = {} + self.module_panels: dict[str, set[str]] = {} + self.module_names: set[str] = set() + self.empty_modules: set[str] = set() + self.table_stack: list[dict[str, int]] = [] + self.empty_table_count = 0 + self.ids: set[str] = set() + self.duplicate_ids: set[str] = set() + self.external_assets: list[str] = [] + self.non_content_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + is_void = tag in VOID_ELEMENTS + if self.current_panel is not None and not is_void: + self.panel_depth += 1 + if not is_void: + for module in self.module_stack: + module["depth"] = int(module["depth"]) + 1 + for table in self.table_stack: + table["depth"] += 1 + if tag in NON_CONTENT_ELEMENTS: + self.non_content_depth += 1 + values = dict(attrs) + element_id = values.get("id") + if element_id: + if element_id in self.ids: + self.duplicate_ids.add(element_id) + self.ids.add(element_id) + if tag == "h1": + self.h1_count += 1 + if values.get("role") == "tab": + self.tabs += 1 + controls = values.get("aria-controls") + if controls: + self.tab_controls.add(controls) + if values.get("role") == "tabpanel": + self.panels += 1 + panel_id = values.get("id") or "" + if panel_id: + self.panel_ids.add(panel_id) + self.panel_modules.setdefault(panel_id, 0) + self.current_panel = panel_id + self.panel_depth = 1 + if tag == "table": + self.table_stack.append({"depth": 1, "data_cells": 0}) + elif tag == "td": + for table in self.table_stack: + table["data_cells"] += 1 + module_name = values.get("data-module") + if module_name: + self.module_names.add(module_name) + self.module_counts[module_name] = self.module_counts.get(module_name, 0) + 1 + if self.current_panel: + self.module_panels.setdefault(module_name, set()).add(self.current_panel) + if is_void: + self.empty_modules.add(module_name) + else: + self.module_stack.append( + {"depth": 1, "name": module_name, "has_meaningful_content": False} + ) + if self.current_panel: + self.panel_modules[self.current_panel] = self.panel_modules.get(self.current_panel, 0) + 1 + if tag == "script" and values.get("src"): + self.external_assets.append(values["src"] or "") + if tag == "link" and "stylesheet" in (values.get("rel") or ""): + self.external_assets.append(values.get("href") or "") + + def handle_startendtag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + self.handle_starttag(tag, attrs) + + def handle_data(self, data: str) -> None: + if data.strip() and not self.non_content_depth: + for module in self.module_stack: + module["has_meaningful_content"] = True + + def handle_endtag(self, tag: str) -> None: + if tag in NON_CONTENT_ELEMENTS and self.non_content_depth: + self.non_content_depth -= 1 + for module in self.module_stack: + module["depth"] = int(module["depth"]) - 1 + while self.module_stack and int(self.module_stack[-1]["depth"]) == 0: + module = self.module_stack.pop() + if not module["has_meaningful_content"]: + self.empty_modules.add(str(module["name"])) + for table in self.table_stack: + table["depth"] -= 1 + while self.table_stack and self.table_stack[-1]["depth"] == 0: + table = self.table_stack.pop() + if table["data_cells"] == 0: + self.empty_table_count += 1 + if self.current_panel is not None: + self.panel_depth -= 1 + if self.panel_depth == 0: + self.current_panel = None + + +def validate(root: Path) -> list[str]: + errors: list[str] = [] + required = (root / "index.html", root / "archive.html", root / ".nojekyll") + for path in required: + if not path.exists(): + errors.append(f"missing built route: {path}") + + manifest_path = root / "reports.json" + if not manifest_path.is_file(): + errors.append(f"missing manifest: {manifest_path}") + return errors + + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + latest = payload.get("latest") + report_paths: list[Path] = [] + for report in payload.get("reports", []): + report_file = root / str(report.get("path", "")) / "index.html" + if not report_file.is_file(): + errors.append(f"missing report: {report_file}") + else: + report_paths.append(report_file) + if not latest: + errors.append("manifest latest is empty") + + archive_path = root / "archive.html" + if archive_path.is_file(): + archive_source = archive_path.read_text(encoding="utf-8") + archive_parser = PageParser() + archive_parser.feed(archive_source) + if archive_parser.h1_count != 1: + errors.append( + f"{archive_path}: expected one h1, found {archive_parser.h1_count}" + ) + if 'data-site-theme="notion-light"' not in archive_source: + errors.append(f"{archive_path}: missing notion-light theme marker") + report_link_count = archive_source.count('class="report-link"') + if report_link_count != len(payload.get("reports", [])): + errors.append( + f"{archive_path}: expected one archive link per report, " + f"found {report_link_count}" + ) + if archive_parser.external_assets: + errors.append( + f"{archive_path}: external assets are not allowed: " + f"{archive_parser.external_assets}" + ) + + pages_to_validate = [root / "index.html", *report_paths] + for index_path in pages_to_validate: + if not index_path.is_file(): + continue + parser = PageParser() + parser.feed(index_path.read_text(encoding="utf-8")) + if parser.h1_count != 1: + errors.append(f"{index_path}: expected one h1, found {parser.h1_count}") + if not 2 <= parser.tabs <= 4 or parser.tabs != parser.panels: + errors.append( + f"{index_path}: expected two to four matching report tabs/panels, " + f"found {parser.tabs}/{parser.panels}" + ) + if parser.tab_controls != parser.panel_ids: + errors.append(f"{index_path}: tab aria-controls values do not match panel ids") + if not {"panel-overview", "panel-method"}.issubset(parser.panel_ids): + errors.append(f"{index_path}: overview and data panels are required") + unexpected_panels = sorted(parser.panel_ids - ALLOWED_PANELS) + if unexpected_panels: + errors.append(f"{index_path}: unexpected panels: {unexpected_panels}") + if "repository-summary" not in parser.module_names: + errors.append(f"{index_path}: missing required repository-summary module") + elif parser.module_panels.get("repository-summary") != {"panel-overview"}: + errors.append( + f"{index_path}: repository-summary must appear in panel-overview" + ) + duplicate_modules = sorted( + name for name, count in parser.module_counts.items() if count > 1 + ) + if duplicate_modules: + errors.append(f"{index_path}: duplicate module names: {duplicate_modules}") + if parser.empty_modules: + errors.append( + f"{index_path}: modules without meaningful content: " + f"{sorted(parser.empty_modules)}" + ) + if parser.empty_table_count: + errors.append( + f"{index_path}: empty tables without data cells: " + f"{parser.empty_table_count}" + ) + if parser.module_stack: + errors.append(f"{index_path}: unclosed data-module element") + if parser.table_stack: + errors.append(f"{index_path}: unclosed table element") + empty_panels = sorted( + panel_id for panel_id, module_count in parser.panel_modules.items() if module_count == 0 + ) + if empty_panels: + errors.append(f"{index_path}: panels without modules: {empty_panels}") + if parser.duplicate_ids: + errors.append(f"{index_path}: duplicate ids: {sorted(parser.duplicate_ids)}") + if parser.external_assets: + errors.append(f"{index_path}: external assets are not allowed: {parser.external_assets}") + if "{{" in index_path.read_text(encoding="utf-8"): + errors.append(f"{index_path}: unresolved template placeholder") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("site", type=Path, nargs="?", default=Path("_site")) + args = parser.parse_args() + errors = validate(args.site.resolve()) + for error in errors: + print(f"ERROR: {error}") + if errors: + print(f"FAIL: {len(errors)} error(s)") + return 1 + print(f"PASS: {args.site.resolve()}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/weekly/site/reports.json b/weekly/site/reports.json new file mode 100644 index 00000000..bc34d775 --- /dev/null +++ b/weekly/site/reports.json @@ -0,0 +1,26 @@ +{ + "latest": "2026-W31", + "reports": [ + { + "week": "2026-W31", + "title": "SkillHub 开源周报|2026 年第 31 周", + "period": "2026-07-23—2026-07-30", + "snapshot": "2026-07-30 16:36 Asia/Shanghai", + "path": "reports/2026-W31/" + }, + { + "week": "2026-W30", + "title": "SkillHub 开源周报|2026 年第 30 周", + "period": "2026-07-16—2026-07-23", + "snapshot": "2026-07-23 22:00 Asia/Shanghai", + "path": "reports/2026-W30/" + }, + { + "week": "2026-W29", + "title": "SkillHub 开源周报|2026 年第 29 周", + "period": "2026-07-09—2026-07-16", + "snapshot": "2026-07-23 22:00 Asia/Shanghai", + "path": "reports/2026-W29/" + } + ] +} diff --git a/weekly/site/reports/2026-W29/index.html b/weekly/site/reports/2026-W29/index.html new file mode 100644 index 00000000..5bcec426 --- /dev/null +++ b/weekly/site/reports/2026-W29/index.html @@ -0,0 +1,1719 @@ + + + + + + + SkillHub 开源周报|2026 年第 29 周 + + + + + + +
    +
    +
    +
    + + SkillHub + 开源周报 +
    + +
    +

    2026 年第 29 周

    +

    项目健康 · 版本交付 · 开源生态 · Issue/PR 流转与价值队列

    +
    +
    +
    +
    + 统计周期 + 2026-07-09 00:00—2026-07-16 23:59(Asia/Shanghai) +
    +
    + 当前状态快照 + 2026-07-23 22:00(Asia/Shanghai) +
    +
    + 项目仓库 + iflytek/skillhub +
    +
    + 数据来源 + GitHub REST API · Actions · Git · npm Downloads API +
    +
    +
    +
    + 总体状态 +
    + 需关注 + Attention +
    +

    安全流水线稳定,但 Actions 有 5 次失败且周度 Release 未完成;长期 Issue/PR 积压仍是主要治理风险。

    +
    +
    +
    +
    4,823Stars · 周增量未取得
    +
    652Forks · 本期新增 189
    +
    92.6%Actions 有效成功率 · 63/68
    +
    0 / 0应用 / CLI Release · W29
    +
    +
    + +
    + +
    + +
    +
    +
    +

    三分钟总览:只保留仓库、迭代和生态三类决策信息。

    +

    详细健康和维护队列请使用上方 Tab。

    +
    + +
    +

    一、仓库关键信息

    +
    +
    +

    规模快照

    +
    +
    + Stars + 4,823 + 周初快照未取得,不能计算自然周增量 +
    +
    + Forks + 652 + 本期新增 189 +
    +
    +

    规模数据截至 7 月 23 日 22:00;缺失值没有用 0 代替。

    +
    +
    +

    本周协作流转

    + +

    Issue 净增 3、PR 净增 4;7 月 23 日快照开放 Issue 21 个、PR 19 个。主分支新增 3 个提交,本期未形成 Merge Commit。

    +
    +
    +

    主要风险:开放 Issue 和 PR 中分别有 52.4% 与 52.6% 已超过 30 天,存量治理弱于新增需求识别。

    +
    + +
    +

    二、功能迭代信息

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    按用户价值排序的重要事项
    状态事项进展与下一步
    期后完成PR #529 双语社区 FAQ7 月 17 日合并并部署,沉淀常见部署与使用问题;不计入 W29 合并量。
    推进中#580 / PR #581 namespace 可见性修复 SUPER_ADMIN 读取边界;需收敛范围并完成门禁。
    期后完成#582 / PR #584 Hermes 集成7 月 22 日合并并关闭 Issue,不计入 W29 交付量。
    期后完成PR #585 CLI 通用安装目标cli-v0.1.9 发布,不计入 W29 交付量。
    待设计#579 来源证明、签名与 SBOM方向符合企业 Registry 定位;建议拆为 fingerprint、签名和 SBOM 三阶段。
    +
    +
    + +
    +

    三、生态相关进展

    + +
      +
    • 社区输入6 位作者提交部署、namespace、OAuth、Hermes 与供应链议题,需求分布覆盖核心治理和生态接入。
    • +
    • 本地化与部署韩语本地化可配置 Base Path部署 Cookie 配置进入 Review。
    • +
    • 期后生态进展双语 FAQ、Hermes 指南和 CLI 通用安装目标在 7 月 17—22 日完成,不计入本期交付量。
    • +
    • 推广与合作本期尚未收到文章、直播、社区分享或合作项目清单;缺失数据不记为 0。
    • +
    +
    + + +
    + + + + + + +
    + +
    + SkillHub Open Source Weekly · 2026-W29 + 公开报告 · 维护者治理视图 +
    +
    + + + + diff --git a/weekly/site/reports/2026-W30/index.html b/weekly/site/reports/2026-W30/index.html new file mode 100644 index 00000000..033534bb --- /dev/null +++ b/weekly/site/reports/2026-W30/index.html @@ -0,0 +1,1713 @@ + + + + + + + SkillHub 开源周报|2026 年第 30 周 + + + + + + +
    +
    +
    +
    + + SkillHub + 开源周报 +
    + +
    +

    2026 年第 30 周

    +

    项目健康 · 版本交付 · 开源生态 · Issue/PR 流转与价值队列

    +
    +
    +
    +
    + 统计周期 + 2026-07-16 00:00—2026-07-23 22:00(Asia/Shanghai) +
    +
    + 当前状态快照 + 2026-07-23 22:00(Asia/Shanghai) +
    +
    + 项目仓库 + iflytek/skillhub +
    +
    + 数据来源 + GitHub REST API · Actions · Git · npm Downloads API +
    +
    +
    +
    + 总体状态 +
    + 需关注 + Attention +
    +

    应用与 CLI 均完成 Release,安全流水线稳定;PR E2E 失败集中、长期 Issue/PR 占比仍高,需要优先治理。

    +
    +
    +
    +
    4,823Stars · 统计期增量未取得
    +
    652Forks · 本期新增 27
    +
    91.3%Actions 有效成功率 · 126/138
    +
    1 / 1应用 / CLI Release · W30
    +
    +
    + +
    + +
    + +
    +
    +
    +

    三分钟总览:只保留仓库、迭代和生态三类决策信息。

    +

    详细健康和维护队列请使用上方 Tab。

    +
    + +
    +

    一、仓库关键信息

    +
    +
    +

    规模快照

    +
    +
    + Stars + 4,823 + 期初快照未取得,不能计算净增量 +
    +
    + Forks + 652 + 本期新增 27 +
    +
    +

    规模快照截至 7 月 23 日 22:00;Fork 新增按创建时间统计,Star 期初净快照未取得。

    +
    +
    +

    本周协作流转

    + +

    Issue 净增 5、当前开放 21;PR 净减 2、当前开放 19。主分支新增 19 个提交,其中 5 个 Merge Commit。

    +
    +
    +

    主要风险:发布目标已经恢复,但开放 Issue 和 PR 中分别有 52.4% 与 52.6% 超过 30 天,且 PR E2E 本期有 8 次失败。

    +
    + +
    +

    二、功能迭代信息

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    按用户价值排序的重要事项
    状态事项进展与下一步
    已完成应用 v0.2.14集成 Hermes 指南、Scanner 依赖修复、通用安装目标及社区 FAQ。
    已完成CLI 0.1.9发布通用用户级 Agent 安装目标,降低跨 Agent 使用门槛。
    已完成PR #584 Hermes Agent 集成指南本周合并并关闭 #582,形成新的 Agent 生态接入路径。
    推进中#596 / PR #601 驳回版本重传 500发布核心流程修复已提交;需解决本周 CI/E2E 失败并完成回归。
    推进中PR #592 标签变更后异步重建搜索索引修复搜索一致性;可观察性增强由 #597 跟踪。
    +
    +
    + +
    +

    三、生态相关进展

    + +
      +
    • Agent 集成Hermes 指南已合并;HarnessClaw Engine 指南 PR #598 继续推进。
    • +
    • 文档沉淀v0.2.14 纳入社区 FAQ;PR #593 继续补充部署与运维问答。
    • +
    • 推广与合作本周尚未收到文章、直播、社区分享或合作项目清单;缺失数据不记为 0。
    • +
    +
    + + +
    + + + + + + +
    + +
    + SkillHub Open Source Weekly · 2026-W30 + 公开报告 · 维护者治理视图 +
    +
    + + + + diff --git a/weekly/site/reports/2026-W31/index.html b/weekly/site/reports/2026-W31/index.html new file mode 100644 index 00000000..f5b35fd9 --- /dev/null +++ b/weekly/site/reports/2026-W31/index.html @@ -0,0 +1,1723 @@ + + + + + + + SkillHub 开源周报|2026 年第 31 周 + + + + + + +
    +
    +
    +
    + + SkillHub + 开源周报 +
    + +
    +

    2026 年第 31 周

    +

    项目健康 · 版本交付 · 开源生态 · Issue/PR 流转与价值队列

    +
    +
    +
    +
    + 统计周期 + 2026-07-23 00:00—2026-07-30 16:36(Asia/Shanghai) +
    +
    + 当前状态快照 + 2026-07-30 16:36(Asia/Shanghai) +
    +
    + 项目仓库 + iflytek/skillhub +
    +
    + 数据来源 + GitHub REST API · Actions · Git · npm Downloads API +
    +
    +
    +
    + 总体状态 +
    + 需关注 + Attention +
    +

    应用 v0.2.15、镜像及 Helm Chart 已完成发布,Actions 保持稳定;Issue 净增 20、开放 P1 达 19 个,是当前主要风险。

    +
    +
    +
    +
    4,945Stars · 较 7 月 23 日 +122*
    +
    702Forks · 本期新增 60
    +
    97.6%Actions 有效成功率 · 240/246
    +
    1 / 0应用 / CLI Release · 本期
    +
    +
    + +
    + +
    + +
    +
    +
    +

    三分钟总览:只保留仓库、迭代和生态三类决策信息。

    +

    详细健康和维护队列请使用上方 Tab。

    +
    + +
    +

    一、仓库关键信息

    +
    +
    +

    规模快照

    +
    +
    + Stars + 4,945 + 较 7 月 23 日 22:00 快照 +122(4,823 → 4,945) +
    +
    + Forks + 702 + 7 月 23 日以来新增 60 +
    +
    +

    Star 变化覆盖 7 月 23 日 22:00 至 7 月 30 日 16:36,未覆盖本期最初 22 小时;Fork 新增按创建时间统计。

    +
    +
    +

    本期协作流转

    + +

    Issue 净增 20,当前开放 40;PR 净减 2,当前开放 15。主分支新增 56 个提交,其中 19 个 Merge Commit。

    +
    +
    +

    主要风险:Issue 输入速度显著高于处置速度,当前开放 P1 19 个、P2 17 个;应用已完成发版,但 CLI 仍需明确本周发布或跳过结论。

    +
    + +
    +

    二、功能迭代信息

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    按用户价值排序的重要事项
    状态事项进展与下一步
    已完成应用 v0.2.15已发布 Helm Chart、Redis Cluster/Sentinel、PostgreSQL/Nginx 部署增强,以及认证、发布与搜索修复;镜像和 Helm Chart 发布工作流均成功。
    已完成认证与 CLI 稳定性修复修复 Device Auth Redis 反序列化Namespace 坐标403 错误语义,并补充 Token 撤销回归测试
    已完成发布与搜索一致性驳回版本重传标签变更后重建搜索索引已合并,关闭两条核心流程缺陷。
    已完成生态与文档补全社区 FAQHarnessClaw 指南README Star/Watch 引导生态入口已合并。
    推进中统一身份联邦架构议题 #628设计文档 #630可信属性 #631全局成员配置 #633仍在审查,尚未合并。
    +
    +
    + +
    +

    三、生态相关进展

    + + +
    + + +
    + + + + + + +
    + +
    + SkillHub Open Source Weekly · 2026-W31 + 公开报告 · 周四结报 · 维护者治理视图 +
    +
    + + + + diff --git a/weekly/source.json b/weekly/source.json new file mode 100644 index 00000000..5c419504 --- /dev/null +++ b/weekly/source.json @@ -0,0 +1,4 @@ +{ + "repository": "https://github.com/XiaoSeS/skillhub-weekly.git", + "commit": "81cabe5ba7a73a938e06bb986d20aaed28fa9695" +}