Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
735ef6b3f2 |
@@ -0,0 +1,4 @@
|
||||
# Default code owners for all files
|
||||
# These users will be automatically requested for review on PRs.
|
||||
|
||||
* @DingTalk-Real-AI/cli-maintainers
|
||||
@@ -3,41 +3,22 @@
|
||||
- What changed?
|
||||
- Why is this change needed?
|
||||
|
||||
## Risk tier
|
||||
|
||||
- [ ] Documentation-only: prose/assets only; no executable, generated, workflow,
|
||||
packaging, or interface behavior changed
|
||||
- [ ] Standard: ordinary implementation change with a stable package graph
|
||||
- [ ] High-risk: workflow/policy, package graph, generated Schema/registry,
|
||||
platform, auth/keychain, installer, packaging, release, transport, recovery,
|
||||
or another fail-closed infrastructure change
|
||||
|
||||
## Verification
|
||||
|
||||
Record the smallest targeted evidence that proves the changed behavior. Do not
|
||||
repeat the entire CI suite locally only to fill this checklist: CI expands the
|
||||
selected tier from documentation checks, through affected-package tests, to
|
||||
the complete high-risk suite.
|
||||
For an exact in-place `CHANGELOG.md`-only pull request, the full-suite checks
|
||||
may be marked `N/A`, but the targeted CHANGELOG check is required. For every
|
||||
other pull request, mark the targeted check `N/A` and complete the applicable
|
||||
full-suite checks.
|
||||
|
||||
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] Targeted test/check commands and results:
|
||||
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
|
||||
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
|
||||
`N/A`)
|
||||
- [ ] Full local suite run because the change is high-risk (optional for other
|
||||
tiers; record command/result or `N/A`)
|
||||
- [ ] `make build`
|
||||
- [ ] `make lint`
|
||||
- [ ] `make test`
|
||||
- [ ] `make policy`
|
||||
- [ ] `./scripts/policy/check-generated-drift.sh`
|
||||
(when generator inputs or generated artifacts may change)
|
||||
- [ ] `./scripts/policy/check-command-surface.sh --strict` (if command surface changed)
|
||||
- [ ] `./scripts/release/verify-package-managers.sh`
|
||||
(after `make package`, if packaging or installer surfaces changed)
|
||||
|
||||
## Notes
|
||||
|
||||
- Any risks, follow-up work, or intentional scope cuts
|
||||
|
||||
The repository automatically requests one eligible peer reviewer, including
|
||||
after a new head push when another review is needed. Once the latest push has
|
||||
peer approval and all nine required checks are current and green, auto-merge
|
||||
completes the PR; authors do not need to coordinate a separate routine merge.
|
||||
|
||||
+108
-479
@@ -25,12 +25,6 @@ jobs:
|
||||
outputs:
|
||||
changelog_only: ${{ steps.classify.outputs.changelog_only }}
|
||||
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
|
||||
docs_only: ${{ steps.classify.outputs.docs_only }}
|
||||
full_suite: ${{ steps.classify.outputs.full_suite }}
|
||||
release_sensitive: ${{ steps.classify.outputs.release_sensitive }}
|
||||
interface_sensitive: ${{ steps.classify.outputs.interface_sensitive }}
|
||||
mcp_sensitive: ${{ steps.classify.outputs.mcp_sensitive }}
|
||||
edition_sensitive: ${{ steps.classify.outputs.edition_sensitive }}
|
||||
platform_sensitive: ${{ steps.classify.outputs.platform_sensitive }}
|
||||
steps:
|
||||
- name: Classify revision scope
|
||||
@@ -40,176 +34,10 @@ jobs:
|
||||
script: |
|
||||
let changelogOnly = false;
|
||||
let changelogChanged = false;
|
||||
let docsOnly = false;
|
||||
let fullSuite = context.eventName === 'push';
|
||||
let releaseSensitive = context.eventName === 'push';
|
||||
let interfaceSensitive = context.eventName === 'push';
|
||||
let mcpSensitive = context.eventName === 'push';
|
||||
let editionSensitive = context.eventName === 'push';
|
||||
let platformSensitive = context.eventName === 'push';
|
||||
let files = [];
|
||||
let fastPathTrust = 'not evaluated';
|
||||
|
||||
const isDocsOnly = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename === 'CHANGELOG.md' ||
|
||||
filename === 'README.md' ||
|
||||
filename === 'README_zh.md' ||
|
||||
filename === 'CONTRIBUTING.md' ||
|
||||
filename === 'SECURITY.md' ||
|
||||
filename === 'CODE_OF_CONDUCT.md' ||
|
||||
filename === 'LICENSE' ||
|
||||
filename === 'NOTICE' ||
|
||||
filename === '.github/PULL_REQUEST_TEMPLATE.md' ||
|
||||
filename.startsWith('.github/ISSUE_TEMPLATE/') ||
|
||||
filename.startsWith('docs/')
|
||||
);
|
||||
const isReleaseSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename.startsWith('scripts/install') ||
|
||||
filename.startsWith('Formula/') ||
|
||||
filename.startsWith('build/') ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === '.github/workflows/release.yml' ||
|
||||
filename === '.github/workflows/withdraw-release.yml'
|
||||
);
|
||||
const isPlatformSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('internal/auth/') ||
|
||||
filename.startsWith('internal/keychain/') ||
|
||||
/_(darwin|windows|linux|unix)(?:_[a-z0-9]+)*\.go$/.test(filename) ||
|
||||
filename === '.github/workflows/ci.yml' ||
|
||||
filename ===
|
||||
'scripts/policy/run-platform-coverage-gate.sh'
|
||||
);
|
||||
const isNativeGoChange = ({ filename, previous_filename, patch }) => {
|
||||
const paths = [filename, previous_filename].filter(
|
||||
(value) => typeof value === 'string' && value !== ''
|
||||
);
|
||||
if (paths.some(isPlatformSensitive)) {
|
||||
return true;
|
||||
}
|
||||
if (!paths.some((value) => value.endsWith('.go'))) {
|
||||
return false;
|
||||
}
|
||||
if (typeof patch !== 'string') {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
/\/\/go:build[^\n]*(darwin|windows|linux|unix)/.test(patch) ||
|
||||
/runtime\.GO(OS|ARCH)/.test(patch)
|
||||
);
|
||||
};
|
||||
const isInterfaceSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('cmd/') ||
|
||||
filename.startsWith('internal/app/') ||
|
||||
filename.startsWith('internal/cli/') ||
|
||||
filename.startsWith('internal/cobracmd/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/i18n/') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/ir/') ||
|
||||
filename.startsWith('internal/shortcut/') ||
|
||||
filename.startsWith('pkg/cmdutil/') ||
|
||||
filename === 'skills_embed.go' ||
|
||||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
|
||||
filename.startsWith('skills/')
|
||||
);
|
||||
const isMCPSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('internal/mcp') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/app/') ||
|
||||
filename.startsWith('test/mock_mcp/')
|
||||
);
|
||||
const isEditionSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('pkg/edition') ||
|
||||
filename.startsWith('internal/edition') ||
|
||||
filename === 'go.mod' ||
|
||||
filename === 'go.sum'
|
||||
);
|
||||
const isHighRisk = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
isPlatformSensitive(filename) ||
|
||||
isReleaseSensitive(filename) ||
|
||||
filename.startsWith('.github/workflows/') ||
|
||||
filename === '.github/actionlint.yaml' ||
|
||||
filename.startsWith('scripts/') ||
|
||||
filename.startsWith('verify/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/app/upgrade') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
filename.startsWith('internal/recovery/') ||
|
||||
filename.startsWith('internal/syncdata/') ||
|
||||
filename.includes('/testdata/') ||
|
||||
filename.startsWith('testdata/') ||
|
||||
filename.startsWith('test/fixtures/') ||
|
||||
filename === 'Makefile' ||
|
||||
filename === 'go.mod' ||
|
||||
filename === 'go.sum'
|
||||
);
|
||||
const classifyFiles = (complete) => {
|
||||
const paths = files.flatMap(({ filename, previous_filename }) =>
|
||||
[filename, previous_filename].filter(
|
||||
(value) => typeof value === 'string' && value !== ''
|
||||
)
|
||||
);
|
||||
const structuralChange = files.some(
|
||||
({ status, previous_filename }) =>
|
||||
status === 'removed' || Boolean(previous_filename)
|
||||
);
|
||||
const goPackageShapeChange = files.some(
|
||||
({ filename, previous_filename, status }) =>
|
||||
['added', 'removed'].includes(status) &&
|
||||
[filename, previous_filename].some(
|
||||
(value) =>
|
||||
typeof value === 'string' && value.endsWith('.go')
|
||||
)
|
||||
);
|
||||
docsOnly =
|
||||
complete &&
|
||||
files.length > 0 &&
|
||||
paths.length > 0 &&
|
||||
paths.every(isDocsOnly);
|
||||
releaseSensitive =
|
||||
!complete || paths.some(isReleaseSensitive);
|
||||
platformSensitive =
|
||||
!complete || files.some(isNativeGoChange);
|
||||
interfaceSensitive =
|
||||
!complete ||
|
||||
paths.some(isInterfaceSensitive) ||
|
||||
paths.some(isEditionSensitive);
|
||||
mcpSensitive =
|
||||
!complete || paths.some(isMCPSensitive);
|
||||
editionSensitive =
|
||||
!complete || paths.some(isEditionSensitive);
|
||||
fullSuite =
|
||||
!complete ||
|
||||
(
|
||||
!docsOnly &&
|
||||
(
|
||||
context.eventName === 'push' ||
|
||||
structuralChange ||
|
||||
goPackageShapeChange ||
|
||||
files.some(isNativeGoChange) ||
|
||||
paths.some(isHighRisk)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if (context.eventName === 'pull_request') {
|
||||
const expectedHead = context.payload.pull_request.head.sha;
|
||||
const expectedBase = context.payload.pull_request.base.sha;
|
||||
@@ -257,14 +85,28 @@ jobs:
|
||||
filename === 'CHANGELOG.md' ||
|
||||
previous_filename === 'CHANGELOG.md'
|
||||
);
|
||||
classifyFiles(true);
|
||||
|
||||
const isPlatformSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('internal/auth/') ||
|
||||
filename.startsWith('internal/keychain/') ||
|
||||
/_(darwin|windows|linux|unix)\.go$/.test(filename) ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename.startsWith('scripts/install') ||
|
||||
filename.startsWith('Formula/') ||
|
||||
filename.startsWith('build/npm/') ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === '.github/workflows/release.yml'
|
||||
);
|
||||
platformSensitive = files.some(
|
||||
({ filename, previous_filename }) =>
|
||||
isPlatformSensitive(filename) ||
|
||||
isPlatformSensitive(previous_filename)
|
||||
);
|
||||
fastPathTrust = changelogOnly
|
||||
? 'exact pull-request revision and synthetic merge policy'
|
||||
: docsOnly
|
||||
? 'documentation-only focused admission'
|
||||
: fullSuite
|
||||
? 'high-risk full admission'
|
||||
: 'changed-package focused admission';
|
||||
: 'full admission required';
|
||||
} else if (context.eventName === 'push') {
|
||||
const expectedBefore = context.payload.before;
|
||||
const expectedAfter = context.payload.after;
|
||||
@@ -294,7 +136,6 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
files = Array.isArray(comparison.files) ? comparison.files : [];
|
||||
classifyFiles(files.length < 300);
|
||||
const linearFromValidatedTip =
|
||||
comparison.status === 'ahead' &&
|
||||
comparison.merge_base_commit?.sha === expectedBefore &&
|
||||
@@ -376,24 +217,12 @@ jobs:
|
||||
|
||||
core.setOutput('changelog_only', String(changelogOnly));
|
||||
core.setOutput('changelog_changed', String(changelogChanged));
|
||||
core.setOutput('docs_only', String(docsOnly));
|
||||
core.setOutput('full_suite', String(fullSuite));
|
||||
core.setOutput('release_sensitive', String(releaseSensitive));
|
||||
core.setOutput('interface_sensitive', String(interfaceSensitive));
|
||||
core.setOutput('mcp_sensitive', String(mcpSensitive));
|
||||
core.setOutput('edition_sensitive', String(editionSensitive));
|
||||
core.setOutput('platform_sensitive', String(platformSensitive));
|
||||
await core.summary
|
||||
.addHeading('Code Admission scope')
|
||||
.addRaw(`- Event: \`${context.eventName}\`\n`)
|
||||
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
|
||||
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
|
||||
.addRaw(`- Documentation-only: \`${docsOnly}\`\n`)
|
||||
.addRaw(`- Full suite: \`${fullSuite}\`\n`)
|
||||
.addRaw(`- Release-sensitive: \`${releaseSensitive}\`\n`)
|
||||
.addRaw(`- Interface-sensitive: \`${interfaceSensitive}\`\n`)
|
||||
.addRaw(`- MCP-sensitive: \`${mcpSensitive}\`\n`)
|
||||
.addRaw(`- Edition-sensitive: \`${editionSensitive}\`\n`)
|
||||
.addRaw(`- Native-platform risk paths touched: \`${platformSensitive}\`\n`)
|
||||
.addRaw(`- Changed files: \`${files.length}\`\n`)
|
||||
.addRaw(`- Fast-path trust: ${fastPathTrust}\n`)
|
||||
@@ -403,92 +232,36 @@ jobs:
|
||||
if: steps.classify.outputs.changelog_only == 'true'
|
||||
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Record documentation-only fast path
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only == 'true'
|
||||
run: echo "Lint is satisfied by the non-executable documentation scope." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check out repository
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify Test Package Plan
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: make test-plan
|
||||
|
||||
- name: Format Check
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: make format-check
|
||||
|
||||
- name: Go Vet
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: go vet ./...
|
||||
|
||||
- name: Check GitHub Actions workflows
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
|
||||
test-focused:
|
||||
name: Test (changed packages)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify authoritative synthetic merge
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || {
|
||||
echo "focused test merge first parent does not match event base" >&2
|
||||
exit 1
|
||||
}
|
||||
test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || {
|
||||
echo "focused test merge second parent does not match event head" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "TEST_BASE_REF=$PR_BASE_SHA" >> "$GITHUB_ENV"
|
||||
echo "TEST_HEAD_REF=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test changed packages and reverse dependencies
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package is affected by this revision." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
mapfile -t packages <<< "$package_output"
|
||||
go test -v -race -count=1 -timeout=8m "${packages[@]}"
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
@@ -526,9 +299,9 @@ jobs:
|
||||
go test -v -race -count=1 -timeout=10m "${packages[@]}"
|
||||
|
||||
test-release-scripts:
|
||||
name: Test (workflow and release contracts)
|
||||
name: Test (release scripts)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.release_sensitive == 'true') }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -558,7 +331,7 @@ jobs:
|
||||
test-cross-platform:
|
||||
name: Test (cross-platform compile)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -590,7 +363,6 @@ jobs:
|
||||
name: Test
|
||||
needs:
|
||||
- lint
|
||||
- test-focused
|
||||
- test-race
|
||||
- test-release-scripts
|
||||
- test-cross-platform
|
||||
@@ -604,10 +376,7 @@ jobs:
|
||||
- name: Verify test shards
|
||||
env:
|
||||
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||
DOCS_ONLY: ${{ needs.lint.outputs.docs_only }}
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
RELEASE_SENSITIVE: ${{ needs.lint.outputs.release_sensitive }}
|
||||
FOCUSED_RESULT: ${{ needs.test-focused.result }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
RACE_RESULT: ${{ needs.test-race.result }}
|
||||
RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }}
|
||||
CROSS_PLATFORM_RESULT: ${{ needs.test-cross-platform.result }}
|
||||
@@ -615,9 +384,8 @@ jobs:
|
||||
WINDOWS_RESULT: ${{ needs.test-windows.result }}
|
||||
run: |
|
||||
failed=0
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT" \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
|
||||
@@ -635,34 +403,21 @@ jobs:
|
||||
exit
|
||||
fi
|
||||
|
||||
focused_expected=success
|
||||
race_expected=skipped
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
focused_expected=skipped
|
||||
race_expected=success
|
||||
fi
|
||||
release_expected=skipped
|
||||
if [ "$FULL_SUITE" = true ] || [ "$RELEASE_SENSITIVE" = true ]; then
|
||||
release_expected=success
|
||||
fi
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT:$focused_expected" \
|
||||
"race shards:$RACE_RESULT:$race_expected" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT:success"
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT"
|
||||
do
|
||||
name="${shard%%:*}"
|
||||
remainder="${shard#*:}"
|
||||
result="${remainder%%:*}"
|
||||
expected="${remainder#*:}"
|
||||
printf '%s: %s (expected %s)\n' "$name" "$result" "$expected"
|
||||
if [ "$result" != "$expected" ]; then
|
||||
result="${shard#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
native_expected=skipped
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
if [ "$PLATFORM_SENSITIVE" = true ]; then
|
||||
native_expected=success
|
||||
fi
|
||||
for native in \
|
||||
@@ -681,7 +436,7 @@ jobs:
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -699,7 +454,7 @@ jobs:
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -813,14 +568,13 @@ jobs:
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
@@ -829,69 +583,15 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
echo "COVERAGE_HEAD_REF=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run current unit tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile=coverage.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
else
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
echo "No buildable Go package needs scoped coverage." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
mapfile -t changed_packages <<< "$changed_output"
|
||||
mapfile -t impacted_packages <<< "$impacted_output"
|
||||
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
|
||||
go test -count=1 -p 1 \
|
||||
-coverpkg="$coverpkg" \
|
||||
-coverprofile=coverage.txt \
|
||||
-covermode=atomic \
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
fi
|
||||
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
|
||||
go tool cover -func=coverage.txt
|
||||
fi
|
||||
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
|
||||
go tool cover -func=coverage.txt
|
||||
|
||||
- name: Upload current coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -903,7 +603,7 @@ jobs:
|
||||
coverage-supporting:
|
||||
name: Coverage (supporting)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -942,7 +642,7 @@ jobs:
|
||||
coverage-baseline:
|
||||
name: Coverage (baseline)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -958,7 +658,6 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
@@ -979,24 +678,8 @@ jobs:
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
changed_output=
|
||||
impacted_output=
|
||||
if [ "$FULL_SUITE" != true ]; then
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" HEAD
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" HEAD
|
||||
)"
|
||||
fi
|
||||
set -eu
|
||||
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
|
||||
rmdir "$base_worktree"
|
||||
cleanup() {
|
||||
@@ -1006,25 +689,11 @@ jobs:
|
||||
git worktree add --detach "$base_worktree" "$COVERAGE_BASE_REF"
|
||||
(
|
||||
cd "$base_worktree"
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
go test -count=1 \
|
||||
-p 1 \
|
||||
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
elif [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > "$GITHUB_WORKSPACE/coverage-base.txt"
|
||||
else
|
||||
mapfile -t changed_packages <<< "$changed_output"
|
||||
mapfile -t impacted_packages <<< "$impacted_output"
|
||||
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
|
||||
go test -count=1 \
|
||||
-p 1 \
|
||||
-coverpkg="$coverpkg" \
|
||||
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
|
||||
-covermode=atomic \
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
go test -count=1 \
|
||||
-p 1 \
|
||||
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
|
||||
- name: Upload baseline coverage profile
|
||||
@@ -1050,8 +719,6 @@ jobs:
|
||||
- name: Verify coverage profile jobs
|
||||
env:
|
||||
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||
DOCS_ONLY: ${{ needs.lint.outputs.docs_only }}
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
@@ -1060,32 +727,22 @@ jobs:
|
||||
WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||
run: |
|
||||
failed=0
|
||||
current_expected=success
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
expected=success
|
||||
native_expected=skipped
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
current_expected=skipped
|
||||
baseline_expected=skipped
|
||||
elif [ "$FULL_SUITE" = true ]; then
|
||||
supporting_expected=success
|
||||
fi
|
||||
if [ "$CHANGELOG_ONLY" != true ] &&
|
||||
[ "$DOCS_ONLY" != true ] &&
|
||||
[ "$PLATFORM_SENSITIVE" = true ]; then
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
expected=skipped
|
||||
elif [ "$PLATFORM_SENSITIVE" = true ]; then
|
||||
native_expected=success
|
||||
fi
|
||||
|
||||
for profile in \
|
||||
"current:$CURRENT_RESULT:$current_expected" \
|
||||
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected"
|
||||
"current:$CURRENT_RESULT" \
|
||||
"supporting:$SUPPORTING_RESULT" \
|
||||
"baseline:$BASELINE_RESULT"
|
||||
do
|
||||
name="${profile%%:*}"
|
||||
remainder="${profile#*:}"
|
||||
result="${remainder%%:*}"
|
||||
expected="${remainder#*:}"
|
||||
printf '%s: %s (expected %s)\n' "$name" "$result" "$expected"
|
||||
result="${profile#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "$expected" ]; then
|
||||
failed=1
|
||||
fi
|
||||
@@ -1097,8 +754,10 @@ jobs:
|
||||
do
|
||||
name="${native%%:*}"
|
||||
result="${native#*:}"
|
||||
printf '%s: %s (expected %s)\n' \
|
||||
"$name" "$result" "$native_expected"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
native_expected=skipped
|
||||
fi
|
||||
if [ "$result" != "$native_expected" ]; then
|
||||
failed=1
|
||||
fi
|
||||
@@ -1106,20 +765,20 @@ jobs:
|
||||
test "$failed" -eq 0
|
||||
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -1137,52 +796,40 @@ jobs:
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-supporting-profiles
|
||||
path: .
|
||||
|
||||
- name: Download baseline coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-baseline-profile
|
||||
path: .
|
||||
|
||||
- name: Enforce coverage gate
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
env:
|
||||
COVERAGE_TARGET: "100"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0"
|
||||
run: |
|
||||
additional_profile=
|
||||
if [ -f coverage-shortcut.txt ]; then
|
||||
additional_profile=coverage-shortcut.txt
|
||||
fi
|
||||
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
|
||||
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
run: COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
run: |
|
||||
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
|
||||
go tool cover -html=coverage.txt -o coverage.html
|
||||
else
|
||||
echo "No buildable Go package needed a coverage HTML report." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: go tool cover -html=coverage.txt -o coverage.html
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
@@ -1192,7 +839,6 @@ jobs:
|
||||
coverage-policy.txt
|
||||
coverage-shortcut.txt
|
||||
coverage.html
|
||||
if-no-files-found: error
|
||||
|
||||
policy:
|
||||
name: Policy
|
||||
@@ -1206,7 +852,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -1284,29 +930,12 @@ jobs:
|
||||
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Validate scoped policy
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && (needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true')) }}
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$PR_BASE_SHA"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
git diff --check "$base_ref" HEAD
|
||||
./scripts/policy/check-open-source-assets.sh
|
||||
echo "Policy used the scoped content/source admission path." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make build
|
||||
|
||||
- name: Policy
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make policy
|
||||
|
||||
interface-integrity:
|
||||
@@ -1316,24 +945,24 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make build
|
||||
|
||||
- name: Resolve authoritative compatibility merge-base
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -1358,7 +987,7 @@ jobs:
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
@@ -1368,16 +997,16 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make skill-command-integrity
|
||||
|
||||
- name: Record scoped fast path
|
||||
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true') }}
|
||||
run: echo "Interface Integrity is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Interface Integrity is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
cli-smoke:
|
||||
name: CLI Smoke
|
||||
@@ -1386,26 +1015,26 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make build
|
||||
|
||||
- name: Check public top-level commands
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make cli-smoke
|
||||
|
||||
- name: Record scoped fast path
|
||||
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true') }}
|
||||
run: echo "CLI Smoke is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "CLI Smoke is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
mock-mcp-smoke:
|
||||
name: Mock MCP
|
||||
@@ -1414,22 +1043,22 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check HTTP and stdio MCP transport
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make mock-mcp-smoke
|
||||
|
||||
- name: Record scoped fast path
|
||||
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.mcp_sensitive != 'true') }}
|
||||
run: echo "Mock MCP is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Mock MCP is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
edition-tests:
|
||||
name: Edition
|
||||
@@ -1438,19 +1067,19 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run edition contract tests
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }}
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
- name: Record scoped fast path
|
||||
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.edition_sensitive != 'true') }}
|
||||
run: echo "Edition is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Edition is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+157
-392
@@ -78,7 +78,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
recover_release_nonce:
|
||||
description: "Unique identifier for this machine-verified recovery request"
|
||||
description: "Unique identifier for this protected recovery request"
|
||||
required: false
|
||||
type: string
|
||||
recover_release_confirmation:
|
||||
@@ -219,6 +219,44 @@ jobs:
|
||||
echo "mode=recover_release" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
authorize-recovery:
|
||||
name: Approve existing tag recovery
|
||||
needs: dispatch-contract
|
||||
if: ${{ needs.dispatch-contract.outputs.mode == 'recover_release' }}
|
||||
environment: release-recovery
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- name: Verify break-glass environment protection
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const response = await github.request(
|
||||
"GET /repos/{owner}/{repo}/environments/{environment_name}",
|
||||
{ owner, repo, environment_name: "release-recovery" },
|
||||
);
|
||||
const reviewerRule = response.data.protection_rules.find(
|
||||
(rule) => rule.type === "required_reviewers",
|
||||
);
|
||||
if (
|
||||
!reviewerRule ||
|
||||
reviewerRule.prevent_self_review !== true ||
|
||||
!Array.isArray(reviewerRule.reviewers) ||
|
||||
reviewerRule.reviewers.length === 0
|
||||
) {
|
||||
core.setFailed("release-recovery must require a reviewer and prevent self-review");
|
||||
return;
|
||||
}
|
||||
if (response.data.deployment_branch_policy?.protected_branches !== true) {
|
||||
core.setFailed("release-recovery must allow only protected branches");
|
||||
}
|
||||
if (response.data.can_admins_bypass !== false) {
|
||||
core.setFailed("release-recovery must not allow administrator bypass");
|
||||
}
|
||||
|
||||
governance-preflight:
|
||||
name: Release governance preflight
|
||||
needs: dispatch-contract
|
||||
@@ -372,9 +410,24 @@ jobs:
|
||||
core.setFailed("Immutable releases must be enabled before publishing");
|
||||
}
|
||||
|
||||
- name: Verify Homebrew PR automation permission
|
||||
env:
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
|
||||
run: |
|
||||
test -n "$HOMEBREW_PR_TOKEN" || {
|
||||
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
|
||||
exit 1
|
||||
}
|
||||
test "$HOMEBREW_PR_TOKEN" != "$RELEASE_GOVERNANCE_TOKEN" || {
|
||||
echo "HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities" >&2
|
||||
exit 1
|
||||
}
|
||||
./scripts/release/verify-homebrew-pr-token.sh "$GITHUB_REPOSITORY" --canary
|
||||
|
||||
release-contract:
|
||||
needs: [dispatch-contract, governance-preflight, release-plan, seal-release]
|
||||
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}
|
||||
needs: [dispatch-contract, authorize-recovery, governance-preflight, release-plan, seal-release]
|
||||
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -515,8 +568,6 @@ jobs:
|
||||
if (isCloudSeal) {
|
||||
const expectedChannel = version.includes("-beta.") ? "prerelease" : "stable";
|
||||
const actorId = String(context.payload.sender?.id || "");
|
||||
const sealedRunAttempt = Number(tagFields.get("Release-Run-Attempt"));
|
||||
const currentRunAttempt = Number(process.env.GITHUB_RUN_ATTEMPT);
|
||||
if (
|
||||
tagFields.get("Channel") !== expectedChannel ||
|
||||
tagFields.get("Sealed-Commit") !== commit ||
|
||||
@@ -531,10 +582,7 @@ jobs:
|
||||
}
|
||||
if (isCreate && (
|
||||
tagFields.get("Release-Run") !== String(context.runId) ||
|
||||
!Number.isSafeInteger(sealedRunAttempt) ||
|
||||
!Number.isSafeInteger(currentRunAttempt) ||
|
||||
sealedRunAttempt < 1 ||
|
||||
sealedRunAttempt > currentRunAttempt ||
|
||||
tagFields.get("Release-Run-Attempt") !== process.env.GITHUB_RUN_ATTEMPT ||
|
||||
tagFields.get("Requested-By") !== context.actor ||
|
||||
tagFields.get("Requested-By-ID") !== actorId
|
||||
)) {
|
||||
@@ -594,7 +642,6 @@ jobs:
|
||||
if (failedByCloud && (
|
||||
tagFields.get("Release-Run") !== failedRunId ||
|
||||
tagFields.get("Release-Run-Attempt") !== failedRunAttempt ||
|
||||
tagFields.get("Requested-By") !== String(run.actor?.login || "") ||
|
||||
tagFields.get("Requested-By-ID") !== String(run.actor?.id || "")
|
||||
)) {
|
||||
core.setFailed(
|
||||
@@ -771,6 +818,23 @@ jobs:
|
||||
core.setFailed("Immutable releases must be enabled before publishing");
|
||||
}
|
||||
|
||||
- name: Verify Homebrew PR automation permission
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && (github.event_name == 'push' || needs.dispatch-contract.outputs.mode == 'recover_release') }}
|
||||
env:
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$HOMEBREW_PR_TOKEN" ]; then
|
||||
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$HOMEBREW_PR_TOKEN" = "$RELEASE_GOVERNANCE_TOKEN" ]; then
|
||||
echo "HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-homebrew-pr-token.sh" \
|
||||
"$GITHUB_REPOSITORY" --canary
|
||||
|
||||
- name: Require successful beta delivery before stable promotion
|
||||
if: ${{ steps.contract.outputs.channel == 'stable' && (github.event_name == 'push' || needs.dispatch-contract.outputs.mode == 'recover_release') }}
|
||||
uses: actions/github-script@v7
|
||||
@@ -1082,7 +1146,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
|
||||
@@ -1436,189 +1499,29 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
||||
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
- name: Publish stable Homebrew formula
|
||||
id: homebrew-stable
|
||||
- name: Open stable Homebrew formula PR
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'stable' }}
|
||||
run: DWS_PUBLISH_OUTPUT="$GITHUB_OUTPUT" "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/publish-homebrew-formula.sh"
|
||||
run: ./scripts/release/publish-homebrew-formula.sh
|
||||
env:
|
||||
DWS_FORMULA_SOURCE: ${{ github.workspace }}/dist/homebrew/dingtalk-workspace-cli.rb
|
||||
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
DWS_TAP_BRANCH: main
|
||||
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ needs.release-contract.outputs.release_version }} [skip ci]"
|
||||
DWS_GIT_NAME: github-actions[bot]
|
||||
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
|
||||
DWS_TAP_PR_BRANCH: "automation/homebrew-${{ needs.release-contract.outputs.release_version }}"
|
||||
DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ needs.release-contract.outputs.release_version }}"
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ needs.release-contract.outputs.release_version }}"
|
||||
|
||||
- name: Publish beta Homebrew formula
|
||||
id: homebrew-beta
|
||||
- name: Open beta Homebrew formula PR
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'prerelease' }}
|
||||
run: DWS_PUBLISH_OUTPUT="$GITHUB_OUTPUT" "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/publish-homebrew-formula.sh"
|
||||
run: ./scripts/release/publish-homebrew-formula.sh
|
||||
env:
|
||||
DWS_FORMULA_SOURCE: ${{ github.workspace }}/dist/homebrew/dingtalk-workspace-cli-beta.rb
|
||||
DWS_FORMULA_SOURCE: dist/homebrew/dingtalk-workspace-cli-beta.rb
|
||||
DWS_TAP_FORMULA_PATH: Formula/dingtalk-workspace-cli-beta.rb
|
||||
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
DWS_TAP_BRANCH: main
|
||||
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ needs.release-contract.outputs.release_version }} [skip ci]"
|
||||
DWS_GIT_NAME: github-actions[bot]
|
||||
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
|
||||
- name: Seal Formula-only Code Admission contexts
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}
|
||||
FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}
|
||||
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const { owner, repo } = context.repo;
|
||||
const commit = process.env.FORMULA_COMMIT;
|
||||
const formulaChanged = process.env.FORMULA_CHANGED === "true";
|
||||
const channel = process.env.RELEASE_CHANNEL;
|
||||
const version = process.env.RELEASE_VERSION;
|
||||
const formulaPath = channel === "stable"
|
||||
? "Formula/dingtalk-workspace-cli.rb"
|
||||
: "Formula/dingtalk-workspace-cli-beta.rb";
|
||||
const sourcePath = channel === "stable"
|
||||
? "dist/homebrew/dingtalk-workspace-cli.rb"
|
||||
: "dist/homebrew/dingtalk-workspace-cli-beta.rb";
|
||||
const expectedMessage = channel === "stable"
|
||||
? `chore: update formula for ${version} [skip ci]`
|
||||
: `chore: update beta formula for ${version} [skip ci]`;
|
||||
|
||||
if (!/^[0-9a-f]{40}$/.test(commit)) {
|
||||
core.setFailed("Homebrew publisher did not return one full commit SHA");
|
||||
return;
|
||||
}
|
||||
const commitResponse = await github.rest.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref: commit,
|
||||
per_page: 100,
|
||||
});
|
||||
const files = commitResponse.data.files || [];
|
||||
const requiredContexts = [
|
||||
"Lint",
|
||||
"Test",
|
||||
"Coverage",
|
||||
"Policy",
|
||||
"Edition",
|
||||
"Interface Integrity",
|
||||
"AI Behavior",
|
||||
"CLI Smoke",
|
||||
"Mock MCP",
|
||||
];
|
||||
const exactFormulaCommit =
|
||||
commitResponse.data.sha === commit &&
|
||||
commitResponse.data.parents.length === 1 &&
|
||||
commitResponse.data.commit.message === expectedMessage &&
|
||||
commitResponse.data.author?.login === "github-actions[bot]" &&
|
||||
commitResponse.data.committer?.login === "github-actions[bot]" &&
|
||||
files.length === 1 &&
|
||||
files[0].filename === formulaPath &&
|
||||
["added", "modified"].includes(files[0].status);
|
||||
if (!exactFormulaCommit) {
|
||||
if (formulaChanged) {
|
||||
core.setFailed(
|
||||
`${commit} is not the exact github-actions Formula-only commit for ${version}`,
|
||||
);
|
||||
} else {
|
||||
core.info("Formula was already current; no Formula-only check sealing is needed.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = commitResponse.data.parents[0].sha;
|
||||
const parentRuns = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: parent,
|
||||
filter: "latest",
|
||||
per_page: 100,
|
||||
});
|
||||
const latestParentRunByName = new Map();
|
||||
for (const run of parentRuns) {
|
||||
if (
|
||||
run.head_sha !== parent ||
|
||||
run.app?.slug !== "github-actions" ||
|
||||
!requiredContexts.includes(run.name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const current = latestParentRunByName.get(run.name);
|
||||
if (!current || run.id > current.id) {
|
||||
latestParentRunByName.set(run.name, run);
|
||||
}
|
||||
}
|
||||
const invalidParentContexts = requiredContexts.filter((name) => {
|
||||
const run = latestParentRunByName.get(name);
|
||||
return !run || run.conclusion !== "success";
|
||||
});
|
||||
if (invalidParentContexts.length > 0) {
|
||||
core.setFailed(
|
||||
`Formula commit parent ${parent} lacks successful Code Admission contexts: ` +
|
||||
invalidParentContexts.join(", "),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteFormula = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: formulaPath,
|
||||
ref: commit,
|
||||
});
|
||||
if (
|
||||
Array.isArray(remoteFormula.data) ||
|
||||
remoteFormula.data.type !== "file" ||
|
||||
remoteFormula.data.encoding !== "base64"
|
||||
) {
|
||||
core.setFailed(`${formulaPath} is not a regular base64 GitHub blob at ${commit}`);
|
||||
return;
|
||||
}
|
||||
const expectedFormula = fs.readFileSync(sourcePath);
|
||||
const actualFormula = Buffer.from(
|
||||
remoteFormula.data.content.replace(/\s/g, ""),
|
||||
"base64",
|
||||
);
|
||||
if (!actualFormula.equals(expectedFormula)) {
|
||||
core.setFailed(`${formulaPath} at ${commit} differs from the verified release Formula`);
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: context.payload.repository.default_branch,
|
||||
});
|
||||
const comparison = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${commit}...${branch.data.commit.sha}`,
|
||||
});
|
||||
if (!["ahead", "identical"].includes(comparison.data.status)) {
|
||||
core.setFailed(`${commit} is no longer contained in the default branch`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const name of requiredContexts) {
|
||||
await github.rest.checks.create({
|
||||
owner,
|
||||
repo,
|
||||
name,
|
||||
head_sha: commit,
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
output: {
|
||||
title: "Verified Formula-only release commit",
|
||||
summary:
|
||||
`${formulaPath} was generated from immutable ${version} assets, ` +
|
||||
"validated byte-for-byte, and was the commit's only changed file.",
|
||||
},
|
||||
});
|
||||
}
|
||||
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
|
||||
DWS_TAP_PR_BRANCH: "automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}"
|
||||
DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ needs.release-contract.outputs.release_version }}"
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ needs.release-contract.outputs.release_version }}"
|
||||
|
||||
- name: Reverify exact immutable npm package
|
||||
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
|
||||
@@ -2146,6 +2049,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
- dispatch-contract
|
||||
- authorize-recovery
|
||||
- governance-preflight
|
||||
- release-contract
|
||||
- release-validation
|
||||
@@ -2168,6 +2072,7 @@ jobs:
|
||||
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
|
||||
GITEE_FALLBACK_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK }}
|
||||
DISPATCH_RESULT: ${{ needs.dispatch-contract.result }}
|
||||
AUTHORIZE_RECOVERY_RESULT: ${{ needs.authorize-recovery.result }}
|
||||
GOVERNANCE_PREFLIGHT_RESULT: ${{ needs.governance-preflight.result }}
|
||||
RELEASE_CONTRACT_RESULT: ${{ needs.release-contract.result }}
|
||||
RELEASE_VALIDATION_RESULT: ${{ needs.release-validation.result }}
|
||||
@@ -2211,6 +2116,7 @@ jobs:
|
||||
case "$EVENT_NAME:$DISPATCH_MODE" in
|
||||
push:)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" skipped
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
@@ -2222,6 +2128,7 @@ jobs:
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
|
||||
require_result release-plan "$RELEASE_PLAN_RESULT" success
|
||||
require_result seal-release "$SEAL_RELEASE_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
require_publication
|
||||
@@ -2230,6 +2137,7 @@ jobs:
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result release-plan "$RELEASE_PLAN_RESULT" success
|
||||
require_result seal-release "$SEAL_RELEASE_RESULT" skipped
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
|
||||
@@ -2243,6 +2151,7 @@ jobs:
|
||||
;;
|
||||
workflow_dispatch:recover_release)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
@@ -2252,6 +2161,7 @@ jobs:
|
||||
workflow_dispatch:governance_preflight)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
|
||||
require_result release "$RELEASE_RESULT" skipped
|
||||
@@ -2266,6 +2176,7 @@ jobs:
|
||||
workflow_dispatch:repair_npm)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
|
||||
@@ -2280,6 +2191,7 @@ jobs:
|
||||
workflow_dispatch:repair_gitee)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
|
||||
@@ -2294,6 +2206,7 @@ jobs:
|
||||
workflow_dispatch:repair_oss)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
|
||||
@@ -2903,94 +2816,10 @@ jobs:
|
||||
repo,
|
||||
branch: defaultBranch,
|
||||
});
|
||||
const currentBranchCommit = branch.data.commit.sha;
|
||||
const branchMoved = branch.data.commit.sha !== commit;
|
||||
|
||||
const currentAttempt = Number(process.env.GITHUB_RUN_ATTEMPT);
|
||||
const requestedById = String(context.payload.sender?.id || "");
|
||||
if (!Number.isSafeInteger(currentAttempt) || currentAttempt < 1 || !/^[1-9]\d*$/.test(requestedById)) {
|
||||
core.setFailed("cloud release request contains an invalid run attempt or requester identity");
|
||||
if (branch.data.commit.sha !== commit) {
|
||||
core.setFailed(`${defaultBranch} moved to ${branch.data.commit.sha}; run the release again`);
|
||||
return;
|
||||
}
|
||||
const messageForAttempt = (attempt) => [
|
||||
`Release ${version}`,
|
||||
"",
|
||||
`Channel: ${channel}`,
|
||||
...(fromBeta ? [`From-Beta: ${fromBeta}`] : []),
|
||||
`OSS-Mirror: ${ossMirror}`,
|
||||
`Release-Run: ${context.runId}`,
|
||||
`Release-Run-Attempt: ${attempt}`,
|
||||
`Requested-By: ${context.actor}`,
|
||||
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
|
||||
`Sealed-Commit: ${commit}`,
|
||||
`Workflow-Commit: ${context.sha}`,
|
||||
`Allocation-Fingerprint: ${expectedFingerprint}`,
|
||||
].join("\n");
|
||||
const retryablePostWriteError = (error) =>
|
||||
error.status === 404 ||
|
||||
error.status === 429 ||
|
||||
(
|
||||
Number.isInteger(error.status) &&
|
||||
error.status >= 500 &&
|
||||
error.status < 600
|
||||
);
|
||||
const postWriteRetryDelaysMs = [0, 1000, 2000, 4000, 8000, 16000];
|
||||
const sleep = (delayMs) =>
|
||||
new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
const readAfterWrite = async (description, operation) => {
|
||||
let lastError;
|
||||
for (const delayMs of postWriteRetryDelaysMs) {
|
||||
if (delayMs > 0) {
|
||||
core.info(`${description} is not visible yet; retrying in ${delayMs}ms`);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!retryablePostWriteError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
};
|
||||
const verifyExactTag = async (refResponse, expectedTagObject = "") => {
|
||||
if (refResponse.data.object.type !== "tag") {
|
||||
throw new Error(`${version} does not point to an annotated tag object`);
|
||||
}
|
||||
const tagObject = refResponse.data.object.sha;
|
||||
if (expectedTagObject && tagObject !== expectedTagObject) {
|
||||
throw new Error(
|
||||
`${version} points to ${tagObject}, expected new tag object ${expectedTagObject}`,
|
||||
);
|
||||
}
|
||||
const tagResponse = await readAfterWrite(
|
||||
`${version} annotated tag object`,
|
||||
() => github.rest.git.getTag({ owner, repo, tag_sha: tagObject }),
|
||||
);
|
||||
const attemptMatches = [
|
||||
...tagResponse.data.message.matchAll(/^Release-Run-Attempt: ([1-9]\d*)$/gm),
|
||||
];
|
||||
if (attemptMatches.length !== 1) {
|
||||
throw new Error(`${version} has invalid Release-Run-Attempt metadata`);
|
||||
}
|
||||
const sealedAttempt = Number(attemptMatches[0][1]);
|
||||
if (
|
||||
!Number.isSafeInteger(sealedAttempt) ||
|
||||
sealedAttempt < 1 ||
|
||||
sealedAttempt > currentAttempt ||
|
||||
tagResponse.data.tag !== version ||
|
||||
tagResponse.data.object.type !== "commit" ||
|
||||
tagResponse.data.object.sha !== commit ||
|
||||
tagResponse.data.message !== messageForAttempt(sealedAttempt)
|
||||
) {
|
||||
throw new Error(
|
||||
`${version} existing annotated tag is not the exact seal from this workflow run`,
|
||||
);
|
||||
}
|
||||
return tagObject;
|
||||
};
|
||||
|
||||
const refs = [];
|
||||
for (const prefix of ["tags/v", "tags/withdrawn/v"]) {
|
||||
@@ -3001,12 +2830,6 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
for (const ref of matching) {
|
||||
// A failed-jobs rerun keeps the successful release-plan output.
|
||||
// Ignore only this run's candidate ref while recomputing that
|
||||
// pre-write fingerprint; verify its complete authority below.
|
||||
if (ref.ref === `refs/tags/${version}`) {
|
||||
continue;
|
||||
}
|
||||
refs.push(`${ref.ref}=${ref.object.sha}`);
|
||||
}
|
||||
}
|
||||
@@ -3020,13 +2843,10 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
let existingRef = null;
|
||||
try {
|
||||
existingRef = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `tags/${version}`,
|
||||
});
|
||||
await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` });
|
||||
core.setFailed(`release tag already exists: ${version}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
@@ -3042,121 +2862,66 @@ jobs:
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
|
||||
if (branchMoved) {
|
||||
if (!existingRef) {
|
||||
core.setFailed(
|
||||
`${defaultBranch} moved to ${currentBranchCommit} before the tag was created; run the release again`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const comparison = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${commit}...${currentBranchCommit}`,
|
||||
});
|
||||
if (!["ahead", "identical"].includes(comparison.data.status)) {
|
||||
core.setFailed(
|
||||
`${version} sealed commit is no longer contained in ${defaultBranch}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message = [
|
||||
`Release ${version}`,
|
||||
"",
|
||||
`Channel: ${channel}`,
|
||||
...(fromBeta ? [`From-Beta: ${fromBeta}`] : []),
|
||||
`OSS-Mirror: ${ossMirror}`,
|
||||
`Release-Run: ${context.runId}`,
|
||||
`Release-Run-Attempt: ${process.env.GITHUB_RUN_ATTEMPT}`,
|
||||
`Requested-By: ${context.actor}`,
|
||||
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
|
||||
`Sealed-Commit: ${commit}`,
|
||||
`Workflow-Commit: ${context.sha}`,
|
||||
`Allocation-Fingerprint: ${expectedFingerprint}`,
|
||||
].join("\n");
|
||||
const tag = await github.rest.git.createTag({
|
||||
owner,
|
||||
repo,
|
||||
tag: version,
|
||||
message,
|
||||
object: commit,
|
||||
type: "commit",
|
||||
});
|
||||
await github.rest.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/tags/${version}`,
|
||||
sha: tag.data.sha,
|
||||
});
|
||||
|
||||
const sealedRef = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `tags/${version}`,
|
||||
});
|
||||
if (
|
||||
sealedRef.data.object.type !== "tag" ||
|
||||
sealedRef.data.object.sha !== tag.data.sha
|
||||
) {
|
||||
core.setFailed(`${version} did not seal to the new annotated tag object`);
|
||||
return;
|
||||
}
|
||||
|
||||
let sealedTagObject = "";
|
||||
let reusedExistingSeal = false;
|
||||
if (existingRef) {
|
||||
sealedTagObject = await verifyExactTag(existingRef);
|
||||
reusedExistingSeal = true;
|
||||
core.info(
|
||||
`Reusing exact ${version} seal from run ${context.runId}; continuing failed jobs without recovery approval`,
|
||||
);
|
||||
} else {
|
||||
const message = messageForAttempt(currentAttempt);
|
||||
const tag = await github.rest.git.createTag({
|
||||
owner,
|
||||
repo,
|
||||
tag: version,
|
||||
message,
|
||||
object: commit,
|
||||
type: "commit",
|
||||
});
|
||||
if (
|
||||
tag.data.tag !== version ||
|
||||
tag.data.object.type !== "commit" ||
|
||||
tag.data.object.sha !== commit ||
|
||||
tag.data.message !== message
|
||||
) {
|
||||
core.setFailed(`${version} annotated tag creation response differs from the seal`);
|
||||
return;
|
||||
}
|
||||
|
||||
let createRefResponse = null;
|
||||
let createRefError = null;
|
||||
for (const delayMs of postWriteRetryDelaysMs) {
|
||||
if (delayMs > 0) {
|
||||
core.info(
|
||||
`${version} ref creation is not confirmed; retrying in ${delayMs}ms`,
|
||||
);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
try {
|
||||
createRefResponse = await github.rest.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/tags/${version}`,
|
||||
sha: tag.data.sha,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
createRefError = error;
|
||||
if (
|
||||
error.status !== 422 &&
|
||||
error.status !== 429 &&
|
||||
!(Number.isInteger(error.status) && error.status >= 500 && error.status < 600)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const observedRef = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `tags/${version}`,
|
||||
});
|
||||
await verifyExactTag(observedRef, tag.data.sha);
|
||||
createRefResponse = observedRef;
|
||||
break;
|
||||
} catch (observeError) {
|
||||
if (!retryablePostWriteError(observeError)) {
|
||||
throw observeError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!createRefResponse) {
|
||||
throw createRefError;
|
||||
}
|
||||
if (
|
||||
createRefResponse.data.object.type !== "tag" ||
|
||||
createRefResponse.data.object.sha !== tag.data.sha
|
||||
) {
|
||||
core.setFailed(`${version} ref creation response differs from the new tag object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sealedRef = await readAfterWrite(
|
||||
`${version} tag ref`,
|
||||
() => github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }),
|
||||
);
|
||||
sealedTagObject = await verifyExactTag(sealedRef, tag.data.sha);
|
||||
const sealedTag = await github.rest.git.getTag({
|
||||
owner,
|
||||
repo,
|
||||
tag_sha: tag.data.sha,
|
||||
});
|
||||
if (
|
||||
sealedTag.data.tag !== version ||
|
||||
sealedTag.data.object.type !== "commit" ||
|
||||
sealedTag.data.object.sha !== commit ||
|
||||
sealedTag.data.message !== message
|
||||
) {
|
||||
core.setFailed(`${version} annotated tag authority differs after creation`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput("release_version", version);
|
||||
core.setOutput("release_commit", commit);
|
||||
core.setOutput("release_tag_object", sealedTagObject);
|
||||
core.setOutput("release_tag_object", tag.data.sha);
|
||||
await core.summary
|
||||
.addHeading("Cloud release sealed")
|
||||
.addRaw(
|
||||
`\`${version}\` → \`${commit}\` (run ${context.runId}` +
|
||||
`${reusedExistingSeal ? ", exact seal reused" : ""})`,
|
||||
)
|
||||
.addRaw(`\`${version}\` → \`${commit}\` (run ${context.runId})`)
|
||||
.write();
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
name: Reviewer routing
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
# pull_request_target deliberately runs only this workflow from the protected
|
||||
# base branch. Never check out or execute pull-request code here.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: reviewer-router-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
route:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Route review and enable auto-merge
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pullNumber = context.payload.pull_request.number;
|
||||
const eventHeadSha = context.payload.pull_request.head.sha;
|
||||
const reviewerPool = [
|
||||
'sczheng189',
|
||||
'shangguanxuan633-lab',
|
||||
'audanye-sudo',
|
||||
'wxianfeng',
|
||||
];
|
||||
|
||||
async function getReadyEventPull(phase) {
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (
|
||||
currentPull.head.sha !== eventHeadSha ||
|
||||
currentPull.state !== 'open' ||
|
||||
currentPull.draft ||
|
||||
currentPull.base.ref !== 'main'
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} state or revision no longer matches this ready-main event during ${phase}; routing stopped.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return currentPull;
|
||||
}
|
||||
const pullRequest = await getReadyEventPull('initial read');
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
const author = pullRequest.user.login.toLowerCase();
|
||||
const headSha = pullRequest.head.sha;
|
||||
const latestPusher =
|
||||
context.payload.action === 'synchronize'
|
||||
? context.payload.sender?.login?.toLowerCase()
|
||||
: author;
|
||||
|
||||
async function routeReview() {
|
||||
const eligible = reviewerPool.filter(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() !== author &&
|
||||
reviewer.toLowerCase() !== latestPusher,
|
||||
);
|
||||
if (eligible.length === 0) {
|
||||
core.warning(`No eligible reviewer remains for PR #${pullNumber}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyRequested =
|
||||
(pullRequest.requested_reviewers || []).length > 0 ||
|
||||
(pullRequest.requested_teams || []).length > 0;
|
||||
if (alreadyRequested) {
|
||||
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let reviews;
|
||||
try {
|
||||
reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not inspect existing reviews for PR #${pullNumber}; skipping reviewer routing to avoid a duplicate request (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestDecisionByLogin = new Map();
|
||||
for (const review of reviews) {
|
||||
const login = review.user?.login?.toLowerCase();
|
||||
if (
|
||||
!login ||
|
||||
!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(
|
||||
review.state,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const previous = latestDecisionByLogin.get(login);
|
||||
if (!previous || review.id > previous.id) {
|
||||
latestDecisionByLogin.set(login, review);
|
||||
}
|
||||
}
|
||||
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
|
||||
review =>
|
||||
review.commit_id === headSha &&
|
||||
eligible.some(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() ===
|
||||
review.user.login.toLowerCase(),
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
|
||||
);
|
||||
if (currentHeadDecision) {
|
||||
core.info(
|
||||
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
|
||||
try {
|
||||
const openPullRequests = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
for (const openPullRequest of openPullRequests) {
|
||||
for (const reviewer of openPullRequest.requested_reviewers || []) {
|
||||
const candidate = eligible.find(
|
||||
login => login.toLowerCase() === reviewer.login.toLowerCase(),
|
||||
);
|
||||
if (candidate) {
|
||||
loads.set(candidate, loads.get(candidate) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not read current reviewer load; using deterministic rotation (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const offset = pullNumber % eligible.length;
|
||||
const rotated = eligible.slice(offset).concat(eligible.slice(0, offset));
|
||||
const tieOrder = new Map(rotated.map((reviewer, index) => [reviewer, index]));
|
||||
const staleChangeRequester = [...latestDecisionByLogin.values()]
|
||||
.filter(review => review.state === 'CHANGES_REQUESTED')
|
||||
.sort((left, right) => right.id - left.id)
|
||||
.map(review =>
|
||||
eligible.find(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() === review.user.login.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.find(Boolean);
|
||||
const ranked = [...eligible].sort(
|
||||
(left, right) =>
|
||||
Number(right === staleChangeRequester) -
|
||||
Number(left === staleChangeRequester) ||
|
||||
loads.get(left) - loads.get(right) ||
|
||||
tieOrder.get(left) - tieOrder.get(right),
|
||||
);
|
||||
|
||||
for (const reviewer of ranked) {
|
||||
try {
|
||||
const currentPull = await getReadyEventPull('review request');
|
||||
if (!currentPull) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(currentPull.requested_reviewers || []).length > 0 ||
|
||||
(currentPull.requested_teams || []).length > 0
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
reviewers: [reviewer],
|
||||
});
|
||||
core.info(
|
||||
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
|
||||
}
|
||||
|
||||
async function enableAutoMerge() {
|
||||
try {
|
||||
const currentPull = await getReadyEventPull('auto-merge enable');
|
||||
if (!currentPull) {
|
||||
return;
|
||||
}
|
||||
if (currentPull.auto_merge) {
|
||||
core.info(`Auto-merge is already enabled for PR #${pullNumber}.`);
|
||||
return;
|
||||
}
|
||||
await github.graphql(
|
||||
`mutation EnableAutoMerge($pullRequestId: ID!) {
|
||||
enablePullRequestAutoMerge(
|
||||
input: {
|
||||
pullRequestId: $pullRequestId
|
||||
mergeMethod: MERGE
|
||||
}
|
||||
) {
|
||||
pullRequest {
|
||||
autoMergeRequest {
|
||||
enabledAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{pullRequestId: currentPull.node_id},
|
||||
);
|
||||
core.info(`Enabled native auto-merge for PR #${pullNumber}.`);
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not enable auto-merge for PR #${pullNumber}; checks and review can continue normally (${error.message}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await routeReview();
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Reviewer routing hit an unexpected error for PR #${pullNumber}; review can still proceed manually (${error.message}).`,
|
||||
);
|
||||
}
|
||||
await enableAutoMerge();
|
||||
+1
-18
@@ -4,23 +4,7 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [1.0.55-beta.3] - 2026-07-24
|
||||
|
||||
This beta validates the HR Brain command surface, smoother guarded release
|
||||
automation, and deterministic Markdown test coverage on top of the
|
||||
`v1.0.55-beta.2` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **HR Brain (`dws hrbrain`) command surface** — adds 11 commands across three groups: `talent-pool list/detail/employees` for talent pool browsing, `profile metadata/query/labels/career/performance` for employee profile data, and `search employees/employees-structured/fields` for basic and advanced (rule-based) people search. Ships with bundled mono/multi Skill guidance (`dingtalk-hrbrain`, `cli_version: ">=1.0.54"`); `search employees-structured` validates `--origin-json` as a JSON object and `--fields` as a JSON array before dispatch.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Smoother guarded releases** — publishes verified stable and beta Homebrew Formula updates directly from the release workflow, retries transient tag-ref visibility failures, lets an exact same-run retry reuse its sealed tag, and allows machine-verified rebuild recovery without a separate approval wait.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deterministic Markdown coverage** — replaces timing-dependent temporary-file deletion tests with synchronized file-stat failures so release admission no longer flakes on scheduler timing.
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.55-beta.2] - 2026-07-23
|
||||
|
||||
@@ -63,7 +47,6 @@ records, encrypted messages, and media-download ID aliases.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut projection silent-empty returns** (#783) — a batch of read shortcuts returned an empty list with exit 0 and no error envelope even when the underlying MCP tool returned data, so agents misread "no data". The projection resolvers now probe the real container keys (`processCodeList`, `values`, `wikiSpaces`, `itemList`, `groupList`, `recentItems`, `emailAccounts`, `deptUserList`, `labelUserList`, `roles`, `report_list`, and the grouped `get_org_labels` `labels[]`), unwrap items nested under a VO wrapper (`shiftVO` / `entityVO` / `userInfo`), and `todo +created-todos` uses the shared pager (`pageSize=20`) because the backend silently returns an empty page for `pageSize>20`. Affects contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart shortcuts, each with a guard test asserting the real response shape projects non-empty. `scripts/shortcut_real_result.py` also gains an upper-vs-lower layer comparison so an exit-0 empty projection over a non-empty backend is scored as `projection-data-loss` in the real read-audit path rather than `real-ok`.
|
||||
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
|
||||
|
||||
## [1.0.54] - 2026-07-21
|
||||
|
||||
+8
-39
@@ -27,9 +27,7 @@ notes that are intentionally kept out of the repository root.
|
||||
|
||||
## Local Checks
|
||||
|
||||
Run the verification commands that match the surface you changed before you
|
||||
hand work back. The goal is useful, change-specific evidence, not a second
|
||||
local execution of every CI job.
|
||||
Run the verification commands that match the surface you changed before you hand work back.
|
||||
|
||||
Common repository checks already used here include:
|
||||
|
||||
@@ -46,48 +44,19 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Select the PR risk tier before choosing checks:
|
||||
|
||||
| Tier | Typical scope | Developer evidence | CI expansion |
|
||||
|---|---|---|---|
|
||||
| Documentation-only | Prose and documentation assets with no executable, generated, workflow, packaging, or interface change | Links/content/rendering plus repository asset checks | Lightweight documentation validation; all nine named contexts still report |
|
||||
| Standard | Ordinary implementation work with a stable package graph | Focused unit/integration tests and observable behavior for the changed path | Race tests for changed packages and their reverse dependencies, scope-matched HEAD/base coverage, and representative Darwin/Windows compilation |
|
||||
| High-risk | Workflow/policy, package graph, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure change | Relevant full or domain suite plus focused behavior evidence | Complete race suite, native platform tests, and all affected domain gates; protected `main` uses this tier |
|
||||
|
||||
Classification fails closed: an incomplete diff, package add/remove/rename, or
|
||||
uncertain dependency graph selects the high-risk suite. Native changed-code
|
||||
coverage is additionally selected for platform-sensitive code.
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
1. Keep implementation and tests in sync.
|
||||
2. Select the documentation-only, standard, or high-risk tier and run the
|
||||
smallest checks that prove the change. Use `./scripts/dev/ci-local.sh` when
|
||||
a complete local pass is warranted; it is not required for every ordinary
|
||||
PR.
|
||||
3. Include both the commands/results and user-visible or contract-level
|
||||
behavior evidence in the PR description.
|
||||
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
|
||||
paths/flags change. CI also runs
|
||||
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
|
||||
against both the target branch and latest stable release.
|
||||
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
|
||||
change.
|
||||
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
|
||||
installer surfaces change (run `make package` first).
|
||||
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
2. Run `./scripts/dev/ci-local.sh`.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
|
||||
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
|
||||
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
|
||||
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
7. Include verification evidence in your PR description.
|
||||
|
||||
## Submission Flow
|
||||
|
||||
1. Make the smallest atomic change that satisfies the task.
|
||||
2. Keep doc edits factual and limited to implemented behavior.
|
||||
3. Run the relevant verification commands.
|
||||
4. Report the validation results and risk tier with the handoff.
|
||||
5. Open a ready PR against `main`. Base-owned automation assigns one eligible
|
||||
peer reviewer, balancing the current open-review load and excluding the
|
||||
author. A new head push re-enters the same routing flow when the latest
|
||||
revision still needs review.
|
||||
6. After the latest push has one peer approval and the exact nine required
|
||||
contexts are current and green, auto-merge completes the PR. If `main`
|
||||
advances first, strict status checks revalidate the branch; no separate
|
||||
routine merge request is needed.
|
||||
4. Report the validation results with the handoff.
|
||||
|
||||
+7
-20
@@ -52,13 +52,7 @@ run.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PR["Pull request"] --> CLASSIFY["Fail-closed risk classification"]
|
||||
CLASSIFY --> DOCS["Documentation-only<br/>asset/content validation"]
|
||||
CLASSIFY --> STANDARD["Standard<br/>affected + reverse-dependent race<br/>scope-matched HEAD/base coverage"]
|
||||
CLASSIFY --> HIGH["High-risk / main<br/>full race + native tests"]
|
||||
DOCS --> CA["CI"]
|
||||
STANDARD --> CA
|
||||
HIGH --> CA
|
||||
PR["Pull request"] --> CA["CI"]
|
||||
subgraph CA_CHECKS["Nine required contexts"]
|
||||
L["Lint"]
|
||||
T["Test"]
|
||||
@@ -78,16 +72,9 @@ flowchart TB
|
||||
PLATFORM --> RELEASE
|
||||
```
|
||||
|
||||
All nine named contexts are produced for every tier. Domain-specific helpers
|
||||
run when their owned surface is affected; otherwise the corresponding context
|
||||
records an explicit unaffected success. Standard code changes still receive
|
||||
representative Darwin/Windows compilation. High-risk PRs and protected `main`
|
||||
run the complete race and native test suites, while platform-sensitive diffs
|
||||
also receive native changed-code coverage.
|
||||
|
||||
Review orchestration is also base-owned: it requests one eligible peer without
|
||||
executing PR code, re-routes an updated head when needed, and auto-merge
|
||||
completes only after the latest push has peer approval plus the current
|
||||
revision's nine strict contexts. Complete Multi-profile E2E remains downstream
|
||||
of PR admission. See [`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact
|
||||
classification, context, reviewer, and ruleset contract.
|
||||
Complete Multi-profile E2E and the ordinary full native-platform matrix are
|
||||
downstream of PR admission. PRs still run primary-environment assurance and
|
||||
fast cross-platform compilation; auth, keychain, OS-specific, installer, and
|
||||
release changes additionally select native platform tests before merge. See
|
||||
[`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact context and ruleset
|
||||
contract.
|
||||
|
||||
+32
-26
@@ -62,27 +62,32 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Homebrew Formula Delivery
|
||||
## Homebrew Formula PR Automation
|
||||
|
||||
Official releases use the Release workflow's built-in `GITHUB_TOKEN` to update
|
||||
exactly one tracked Formula after the immutable GitHub assets and their
|
||||
checksums have passed verification. The publisher validates the rendered Ruby,
|
||||
commits only the configured Formula path, never force-pushes `main`, and retries
|
||||
from a fresh clone up to three times when `main` advances concurrently. Normal
|
||||
stable and beta releases do not create a Formula PR or run a permission
|
||||
canary. The workflow uses the existing repository-scoped
|
||||
`HOMEBREW_PR_TOKEN` release identity because GitHub does not allow its built-in
|
||||
Actions App to bypass this repository's rulesets. That identity is the sole
|
||||
user bypass actor on the two default-branch rulesets. The workflow creates the
|
||||
nine Code Admission checks for the Formula-only commit only after proving its
|
||||
sole parent already has all nine successful checks and the committed Formula
|
||||
exactly matches this release's verified bytes.
|
||||
Official tag releases require the repository Actions secret
|
||||
`HOMEBREW_PR_TOKEN`. Prefer a fine-grained personal access token owned by a
|
||||
maintainer or release-bot account, limited to this repository with
|
||||
`Contents: write` and `Pull requests: write`. If organization policy prevents
|
||||
that account from targeting the repository, use a dedicated classic token with
|
||||
only the `public_repo` scope. Do not reuse a broad developer token.
|
||||
|
||||
Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and
|
||||
`Pull requests: write` (the latter remains necessary for withdrawal rollback),
|
||||
keep its owner as the designated ruleset bypass actor, and do not reuse
|
||||
`RELEASE_GOVERNANCE_TOKEN`. The workflow and publisher provide the Formula-only
|
||||
path restriction; GitHub rulesets do not infer that restriction from the token.
|
||||
Store the dedicated token as the `HOMEBREW_PR_TOKEN` repository Actions secret
|
||||
and rotate it before its configured expiration. Replace it immediately if it is
|
||||
exposed, its owner loses repository access, or the release-bot ownership
|
||||
changes. The Release workflow uses this
|
||||
dedicated token only to push an `automation/homebrew-*` branch and open the
|
||||
stable or beta Formula PR. It does not push Formula changes directly to `main`.
|
||||
The default-branch governance preflight and every tag contract authenticate the
|
||||
token before publication, reject over-scoped classic tokens, confirm its
|
||||
identity, and run a controlled write canary. The canary pushes a unique
|
||||
`automation/homebrew-token-canary-*` branch with a `[skip ci]` commit, creates a
|
||||
draft PR, closes it, and deletes the branch with the same token. This proves both
|
||||
Contents and Pull requests write access before publication without merging
|
||||
anything. The gate also rejects reuse of `RELEASE_GOVERNANCE_TOKEN`.
|
||||
No maintainer environment variable is required when creating a tag. Using the
|
||||
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
|
||||
Actions from creating pull requests, and its generated PR events may require
|
||||
separate workflow approval.
|
||||
|
||||
## Release Governance and Recovery
|
||||
|
||||
@@ -93,14 +98,15 @@ administration setting and cannot be read by the workflow's built-in
|
||||
contract use this same credential so a missing or expired identity is detected
|
||||
before an irreversible tag is created.
|
||||
|
||||
Create a protected `release-recovery` environment limited to protected
|
||||
branches, with a required reviewer, self-review disabled, and administrator
|
||||
bypass disabled. The workflow reads the environment through the GitHub API and
|
||||
fails closed unless the required-reviewer, prevent-self-review, and protected-
|
||||
branch rules are present.
|
||||
Recovery is restricted to an existing annotated tag whose exact tag object,
|
||||
commit, sealed metadata, original failed run/attempt, requester identity and
|
||||
Release state all match; it then reuses the normal release jobs without a
|
||||
second-person environment approval. A same-run “Re-run failed jobs” is even
|
||||
lighter: the seal job may adopt an existing tag only when its complete
|
||||
authority matches that run and its original attempt is not newer than the
|
||||
current attempt. Do not put publication secrets in temporary branches or
|
||||
create ad-hoc recovery workflows.
|
||||
commit, and failed tag-push run all match; it then reuses the normal release
|
||||
jobs. Do not put publication secrets in temporary branches or create ad-hoc
|
||||
recovery workflows.
|
||||
|
||||
Cloud-sealed releases mirror to OSS only when the repository variable
|
||||
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
|
||||
|
||||
+18
-59
@@ -4,9 +4,9 @@ The pull-request admission layer has exactly nine required external contexts:
|
||||
|
||||
| Required context | Contract |
|
||||
|---|---|
|
||||
| `Lint` | Stable PR revision/risk classification plus applicable formatting, `go vet`, and Actionlint |
|
||||
| `Test` | Tier-selected race/unit/release-script tests plus representative cross-platform compilation |
|
||||
| `Coverage` | Scope-matched overall non-regression and 100% changed-code coverage |
|
||||
| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint |
|
||||
| `Test` | Race/unit/release-script tests plus fast cross-platform compilation |
|
||||
| `Coverage` | Overall non-regression and 100% changed-code coverage |
|
||||
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
|
||||
| `Edition` | Edition contract tests |
|
||||
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
|
||||
@@ -84,28 +84,13 @@ second file but still rejects invalid dates or versions, missing bullets,
|
||||
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
|
||||
Adding a second file therefore cannot bypass CHANGELOG validation.
|
||||
|
||||
## Risk tiers and downstream boundaries
|
||||
## Platform and downstream boundaries
|
||||
|
||||
`Lint` resolves the complete base/head diff before any helper is skipped.
|
||||
Unknown or truncated input fails closed into the high-risk tier.
|
||||
|
||||
| Tier | Selection | Admission work |
|
||||
|---|---|---|
|
||||
| Documentation-only | Only prose/documentation assets; no executable, generated, workflow, packaging, or interface surface | Documentation and repository-asset validation; expensive code helpers skip while every required context still succeeds |
|
||||
| Standard | Ordinary code change with a stable package graph | Race tests for changed Go packages and their reverse dependencies; candidate and merge-base coverage over the same impacted scope and `coverpkg`; representative Darwin/Windows compilation |
|
||||
| High-risk / protected `main` | Workflow/policy, package add/remove/rename, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure classification | Complete race suite and full native macOS/Windows tests, plus every affected domain gate |
|
||||
|
||||
Domain helpers (`Edition`, `Interface Integrity`, `CLI Smoke`, and `Mock MCP`,
|
||||
for example) execute their substantive suites when the diff can affect that
|
||||
contract or when the high-risk tier is selected. Otherwise their stable named
|
||||
contexts still report a successful, explicit unaffected result. Release-script
|
||||
tests follow the same impact rule. This preserves the ruleset contract without
|
||||
charging every developer for unrelated work.
|
||||
|
||||
Platform-sensitive changes additionally run native changed-code coverage.
|
||||
Protected `main` always runs native tests; generic portable changes are held to
|
||||
the Linux changed-code gate rather than being forced to manufacture
|
||||
platform-only coverage.
|
||||
Ordinary PRs run the primary Linux assurance plus fast Darwin/Windows compile
|
||||
checks. Full native macOS/Windows tests and platform coverage run on a PR only
|
||||
when its diff touches auth, keychain, OS-specific Go files, installers,
|
||||
packaging, Formulae, or release automation. Protected `main` pushes run the
|
||||
complete native matrix.
|
||||
|
||||
Complete `Multi-profile E2E` is not a PR admission context. It belongs to the
|
||||
`Main Integration — 主干集成` workflow and runs only after a push to `main` (or
|
||||
@@ -131,28 +116,9 @@ flowchart TB
|
||||
MAIN --> RELEASE["Release delivery"]
|
||||
```
|
||||
|
||||
## Review ownership and auto-merge
|
||||
|
||||
A base-owned `pull_request_target` workflow routes newly opened, updated,
|
||||
reopened, or newly ready PRs targeting `main` to one eligible peer reviewer. It
|
||||
does not check out or execute PR code, excludes both the author and the known
|
||||
latest pusher, and balances the open requested-review load across the reviewed
|
||||
maintainer pool. A current-head approval or change request is preserved; after
|
||||
a new push, stale activity does not suppress a fresh request, and an
|
||||
outstanding change requester is preferred for continuity.
|
||||
|
||||
The branch ruleset keeps one human approval and all nine strict required
|
||||
contexts, and requires someone other than the latest pusher to approve after
|
||||
the most recent head update. Repository auto-merge is enabled for ready PRs,
|
||||
so a PR merges after that approval and the current revision's nine checks are
|
||||
green. If `main` advances, strict checks rerun before merge. The reviewer
|
||||
router is orchestration, not a quality context, and must not be added to the
|
||||
ruleset.
|
||||
|
||||
## Running focused gates locally
|
||||
|
||||
Run the contracts relevant to the change. Ordinary contributors are not
|
||||
expected to repeat every CI job locally:
|
||||
Run the contracts relevant to the change:
|
||||
|
||||
```sh
|
||||
make build
|
||||
@@ -173,18 +139,15 @@ base_ref=$(git merge-base HEAD origin/main)
|
||||
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
|
||||
```
|
||||
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. For a
|
||||
standard PR, CI derives changed packages and their reverse-dependency test
|
||||
closure, then generates candidate and merge-base profiles with the same test
|
||||
scope and `coverpkg`. High-risk and protected-main runs use the complete
|
||||
profiles. Supporting and (when platform-selected) native profiles are
|
||||
generated before the aggregate `Coverage` context evaluates them. The
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. CI
|
||||
generates the candidate, supporting, merge-base, and (when risk-selected)
|
||||
native profiles before the aggregate `Coverage` context evaluates them. The
|
||||
aggregate and native gates require 100% coverage for changed executable Go
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance,
|
||||
scope-matched merge-base non-regression check. Candidate and baseline profiles
|
||||
are evaluated by the same block-deduplicating checker; supporting policy and
|
||||
shortcut profiles contribute to changed-code coverage only. The checked-in
|
||||
badge is presentation only and is never read as a gate input.
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance merge-base
|
||||
non-regression check. Candidate and baseline profiles are evaluated by the
|
||||
same block-deduplicating checker; supporting policy and shortcut profiles
|
||||
contribute to changed-code coverage only. The checked-in badge is presentation
|
||||
only and is never read as a gate input.
|
||||
|
||||
Compatibility checks derive authoritative Interface snapshots from the PR
|
||||
merge-base and the latest reachable stable release. The candidate cannot bless
|
||||
@@ -212,7 +175,3 @@ Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission
|
||||
alias. Update ruleset contexts only after the new names have appeared on the
|
||||
protected branch, so a rename cannot silently remove enforcement or leave an
|
||||
unproducible required context.
|
||||
|
||||
The branch ruleset also requires one approval after the latest push. Enable
|
||||
repository auto-merge and automatic head-branch deletion; keep the base-owned
|
||||
reviewer router outside the required-context list.
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
# 发布手册(预发 / 正式)
|
||||
|
||||
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew Formula 在不可变 Release 资产及 checksum 通过校验后,由同一 workflow 直接写入 `main`,不再创建二次 PR。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
|
||||
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
|
||||
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
|
||||
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
|
||||
@@ -132,14 +132,14 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须是 annotated tag;本地脚本要求封板提交已通过 PR 合入并包含在远端 `main` 历史中,发布只推送 tag。CI 允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时先重跑同一 run 的失败 jobs,必须跨 run 时走机器核验恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
|
||||
|
||||
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
|
||||
|
||||
@@ -166,17 +166,17 @@ dws-release recover v1.2.3-beta.1
|
||||
|
||||
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
|
||||
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
|
||||
- 恢复不再进入人工审批 environment。workflow 会机器核验 tag object、commit、原失败 run/attempt、请求人、完整 seal metadata、`main` 祖先关系以及 Release 状态;任一事实不一致都会在构建前 fail closed。
|
||||
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
|
||||
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm,以及已启用的 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
|
||||
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
|
||||
|
||||
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
|
||||
|
||||
seal job 写入 tag 后如果只因 GitHub API 瞬时 404/429/5xx 或后续 job 失败,可直接使用 GitHub 的 “Re-run failed jobs”。同一 run 会精确复用原 release-plan;seal 只在 version、tag object、commit、channel、beta 来源、OSS policy、请求人、run ID 和完整 message 全部匹配且原 attempt 不大于当前 attempt 时认领已有 tag。不同 run 或任一字段不匹配时不会认领。GitHub Release 尚未公开且必须跨 run 重建时走上述机器核验 recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
|
||||
云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
|
||||
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并在不可变资产逐个校验后,由 `HOMEBREW_PR_TOKEN` 所属的受控发布身份只提交对应 stable 或 beta Formula 文件到 `main`,不再创建二次发布 PR;并发 `main` 更新会以全新 clone 最多重试三次,绝不 force push。该身份的提交不会依赖另一轮 CI 来补齐证明:workflow 只在确认该 commit 单父、唯一改动为目标 Formula、内容与本次已验证产物逐字节一致,且父 commit 九项 Code Admission 全绿后,直接为 Formula-only commit 封存同名九项成功 checks,避免下一次发布因缺失 contexts 被卡住。撤回 workflow 暂时仍使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
|
||||
## 平台治理前置
|
||||
|
||||
@@ -189,9 +189,9 @@ Release workflow 会生成 Darwin/Linux 双架构 Formula,并在不可变资
|
||||
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
|
||||
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
|
||||
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
|
||||
- 正常 Homebrew 发布使用现有的 `HOMEBREW_PR_TOKEN` 直接提交 Formula-only commit,不再创建 Homebrew PR,也不跑权限 canary。GitHub 不允许内置 Actions App 作为当前仓库 ruleset 的 bypass actor,因此两个默认分支 ruleset 都只给该 token 所属的指定发布管理员用户 `always` bypass;仓库脚本仍会限制提交路径、校验 Ruby、禁止 force push,并在并发更新时重新基于最新 `main`。
|
||||
- `HOMEBREW_PR_TOKEN` 应保持仓库范围的 `Contents: write` 与 `Pull requests: write` 权限;后者仅供撤回流程创建回退 PR。不要与 `RELEASE_GOVERNANCE_TOKEN` 复用,并定期审计 token owner 与 ruleset bypass actor 一致。
|
||||
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
|
||||
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
|
||||
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
|
||||
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的权限;正常发布由 `HOMEBREW_PR_TOKEN` 更新两个受控 Formula 路径,内置 token 只承担 workflow 自身声明的封板与校验写入。若撤回凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
|
||||
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
|
||||
|
||||
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:bda812c0b48f221a70f9c710d56a4dead5916bf425a9b153e7b275d15365f1a0",
|
||||
"surface_hash": "sha256:8eef4235b4391a6a865c180ebd3a65ca3531a84c6985b37811f23fff9daf2e3a",
|
||||
"source_hash": "sha256:be0a967cd36b8430c22dd27fbd9a01ec232d0ce07b8a5ca3f9d35f2554ecbbe4",
|
||||
"surface_hash": "sha256:2c84c53f5844050980fb5e1459c92e3afbd701357e125608f4c41a43abc9bdd5",
|
||||
"coverage": {
|
||||
"surface_products": 25,
|
||||
"products_with_metadata": 25,
|
||||
"surface_tools": 603,
|
||||
"tools_with_metadata": 603,
|
||||
"tools_with_agent_summary": 603,
|
||||
"tools_with_use_when": 603,
|
||||
"tools_with_avoid_when": 603,
|
||||
"tools_with_examples": 603,
|
||||
"tools_with_interface_mode": 603,
|
||||
"surface_products": 24,
|
||||
"products_with_metadata": 24,
|
||||
"surface_tools": 592,
|
||||
"tools_with_metadata": 592,
|
||||
"tools_with_agent_summary": 592,
|
||||
"tools_with_use_when": 592,
|
||||
"tools_with_avoid_when": 592,
|
||||
"tools_with_examples": 592,
|
||||
"tools_with_interface_mode": 592,
|
||||
"unmatched_skill_tools": 118,
|
||||
"unreviewed_skill_tools": 7
|
||||
},
|
||||
@@ -941,78 +941,6 @@
|
||||
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
|
||||
]
|
||||
},
|
||||
"hrbrain": {
|
||||
"agent_summary": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
|
||||
"agent_summary_source": "dws-agent-selection/hrbrain",
|
||||
"avoid_when": [
|
||||
"要操作通讯录/组织架构基础信息时改用 contact 产品",
|
||||
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"要操作通讯录/组织架构基础信息时改用 contact 产品",
|
||||
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"要操作通讯录/组织架构基础信息时改用 contact 产品",
|
||||
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"source_refs": [
|
||||
"CommandRegistry:product=hrbrain",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md",
|
||||
"internal/cli/schema_hints/selection/hrbrain.json",
|
||||
"skills/mono/SKILL.md"
|
||||
],
|
||||
"use_when": [
|
||||
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
|
||||
]
|
||||
},
|
||||
"live": {
|
||||
"agent_summary": "查询当前用户发起的直播列表",
|
||||
"agent_summary_source": "dws-agent-selection/live",
|
||||
@@ -1811,7 +1739,6 @@
|
||||
"doc",
|
||||
"drive",
|
||||
"event",
|
||||
"hrbrain",
|
||||
"live",
|
||||
"mail",
|
||||
"markdown",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:bda812c0b48f221a70f9c710d56a4dead5916bf425a9b153e7b275d15365f1a0",
|
||||
"surface_hash": "sha256:8eef4235b4391a6a865c180ebd3a65ca3531a84c6985b37811f23fff9daf2e3a",
|
||||
"source_files": 158,
|
||||
"hint_files": 52,
|
||||
"hint_products": 42,
|
||||
"hint_tools": 1358,
|
||||
"source_hash": "sha256:be0a967cd36b8430c22dd27fbd9a01ec232d0ce07b8a5ca3f9d35f2554ecbbe4",
|
||||
"surface_hash": "sha256:2c84c53f5844050980fb5e1459c92e3afbd701357e125608f4c41a43abc9bdd5",
|
||||
"source_files": 155,
|
||||
"hint_files": 50,
|
||||
"hint_products": 41,
|
||||
"hint_tools": 1336,
|
||||
"interface_metadata": {
|
||||
"source": "mcp-tools-list+cli-registry",
|
||||
"revision": "4574f7022c32cf4c033e9b7b4156e2fec815fed8",
|
||||
@@ -27,15 +27,15 @@
|
||||
]
|
||||
},
|
||||
"coverage": {
|
||||
"surface_products": 25,
|
||||
"products_with_metadata": 25,
|
||||
"surface_tools": 603,
|
||||
"tools_with_metadata": 603,
|
||||
"tools_with_agent_summary": 603,
|
||||
"tools_with_use_when": 603,
|
||||
"tools_with_avoid_when": 603,
|
||||
"tools_with_examples": 603,
|
||||
"tools_with_interface_mode": 603,
|
||||
"surface_products": 24,
|
||||
"products_with_metadata": 24,
|
||||
"surface_tools": 592,
|
||||
"tools_with_metadata": 592,
|
||||
"tools_with_agent_summary": 592,
|
||||
"tools_with_use_when": 592,
|
||||
"tools_with_avoid_when": 592,
|
||||
"tools_with_examples": 592,
|
||||
"tools_with_interface_mode": 592,
|
||||
"unmatched_skill_tools": 118,
|
||||
"unreviewed_skill_tools": 7
|
||||
},
|
||||
@@ -58,7 +58,6 @@
|
||||
"event",
|
||||
"extension",
|
||||
"generate-topic-report",
|
||||
"hrbrain",
|
||||
"live",
|
||||
"mail",
|
||||
"markdown",
|
||||
@@ -163,7 +162,7 @@
|
||||
{
|
||||
"tool_path": "calendar participant delete",
|
||||
"source": "skills/mono/SKILL.md",
|
||||
"line": 148,
|
||||
"line": 145,
|
||||
"candidates": [
|
||||
"calendar attendee delete",
|
||||
"calendar event delete",
|
||||
@@ -664,7 +663,6 @@
|
||||
"source": "skills/mono/references/products/agoal.md",
|
||||
"line": 65,
|
||||
"candidates": [
|
||||
"hrbrain talent-pool detail",
|
||||
"oa approval detail"
|
||||
],
|
||||
"review": {
|
||||
@@ -705,8 +703,7 @@
|
||||
"source": "skills/mono/references/products/agoal.md",
|
||||
"line": 70,
|
||||
"candidates": [
|
||||
"contact user profile fields",
|
||||
"hrbrain search fields"
|
||||
"contact user profile fields"
|
||||
],
|
||||
"review": {
|
||||
"status": "out_of_surface",
|
||||
@@ -718,7 +715,6 @@
|
||||
"source": "skills/mono/references/products/agoal.md",
|
||||
"line": 71,
|
||||
"candidates": [
|
||||
"hrbrain talent-pool detail",
|
||||
"oa approval detail"
|
||||
],
|
||||
"review": {
|
||||
@@ -745,7 +741,6 @@
|
||||
"source": "skills/mono/references/products/agoal.md",
|
||||
"line": 75,
|
||||
"candidates": [
|
||||
"hrbrain talent-pool detail",
|
||||
"oa approval detail"
|
||||
],
|
||||
"review": {
|
||||
@@ -866,7 +861,6 @@
|
||||
"source": "skills/mono/references/products/agoal.md",
|
||||
"line": 97,
|
||||
"candidates": [
|
||||
"hrbrain talent-pool detail",
|
||||
"oa approval detail"
|
||||
],
|
||||
"review": {
|
||||
@@ -907,8 +901,7 @@
|
||||
"source": "skills/mono/references/products/agoal.md",
|
||||
"line": 106,
|
||||
"candidates": [
|
||||
"contact user profile fields",
|
||||
"hrbrain search fields"
|
||||
"contact user profile fields"
|
||||
],
|
||||
"review": {
|
||||
"status": "out_of_surface",
|
||||
@@ -920,7 +913,6 @@
|
||||
"source": "skills/mono/references/products/agoal.md",
|
||||
"line": 107,
|
||||
"candidates": [
|
||||
"hrbrain talent-pool detail",
|
||||
"oa approval detail"
|
||||
],
|
||||
"review": {
|
||||
@@ -947,7 +939,6 @@
|
||||
"source": "skills/mono/references/products/agoal.md",
|
||||
"line": 115,
|
||||
"candidates": [
|
||||
"hrbrain talent-pool detail",
|
||||
"oa approval detail"
|
||||
],
|
||||
"review": {
|
||||
|
||||
+11
-7195
File diff suppressed because it is too large
Load Diff
@@ -1602,55 +1602,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hrbrain",
|
||||
"tools": [
|
||||
{
|
||||
"canonical_path": "hrbrain.list_talent_pools",
|
||||
"cli_path": "hrbrain talent-pool list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "hrbrain.get_talent_pool_detail",
|
||||
"cli_path": "hrbrain talent-pool detail"
|
||||
},
|
||||
{
|
||||
"canonical_path": "hrbrain.list_pool_employees",
|
||||
"cli_path": "hrbrain talent-pool employees"
|
||||
},
|
||||
{
|
||||
"canonical_path": "hrbrain.get_profile_metadata",
|
||||
"cli_path": "hrbrain profile metadata"
|
||||
},
|
||||
{
|
||||
"canonical_path": "hrbrain.query_profile_data",
|
||||
"cli_path": "hrbrain profile query"
|
||||
},
|
||||
{
|
||||
"canonical_path": "hrbrain.get_profile_label",
|
||||
"cli_path": "hrbrain profile labels"
|
||||
},
|
||||
{
|
||||
"canonical_path": "hrbrain.get_employee_career",
|
||||
"cli_path": "hrbrain profile career"
|
||||
},
|
||||
{
|
||||
"canonical_path": "hrbrain.get_employee_performance",
|
||||
"cli_path": "hrbrain profile performance"
|
||||
},
|
||||
{
|
||||
"canonical_path": "hrbrain.search_employees",
|
||||
"cli_path": "hrbrain search employees"
|
||||
},
|
||||
{
|
||||
"canonical_path": "hrbrain.search_employees_structured",
|
||||
"cli_path": "hrbrain search employees-structured"
|
||||
},
|
||||
{
|
||||
"canonical_path": "hrbrain.get_search_fields",
|
||||
"cli_path": "hrbrain search fields"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "live",
|
||||
"tools": [
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"doc": "metadata/doc.json",
|
||||
"drive": "metadata/drive.json",
|
||||
"event": "metadata/event.json",
|
||||
"hrbrain": "metadata/hrbrain.json",
|
||||
"live": "metadata/live.json",
|
||||
"mail": "metadata/mail.json",
|
||||
"markdown": "metadata/markdown.json",
|
||||
@@ -48,7 +47,6 @@
|
||||
"doc": "selection/doc.json",
|
||||
"drive": "selection/drive.json",
|
||||
"event": "selection/event.json",
|
||||
"hrbrain": "selection/hrbrain.json",
|
||||
"live": "selection/live.json",
|
||||
"mail": "selection/mail.json",
|
||||
"markdown": "selection/markdown.json",
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source": {
|
||||
"kind": "explicit",
|
||||
"name": "dws-tool-metadata/hrbrain",
|
||||
"repository": "DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
"channel": "open-source",
|
||||
"reviewed": true
|
||||
},
|
||||
"tools": {
|
||||
"hrbrain.list_talent_pools": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool list_talent_pools, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain talent-pool list",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the list_talent_pools MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"keyword": {
|
||||
"property": "keyword",
|
||||
"required": false
|
||||
},
|
||||
"pool-type": {
|
||||
"property": "poolType",
|
||||
"required": false
|
||||
},
|
||||
"creator": {
|
||||
"property": "creator",
|
||||
"required": false
|
||||
},
|
||||
"labels": {
|
||||
"property": "labels",
|
||||
"required": false
|
||||
},
|
||||
"page": {
|
||||
"property": "currentPage",
|
||||
"required": false
|
||||
},
|
||||
"page-size": {
|
||||
"property": "pageSize",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_talent_pool_detail": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_talent_pool_detail, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain talent-pool detail",
|
||||
"review_reason": "Reviewed unpinned remote adapter: --pool-code maps 1:1 to the poolCode MCP call argument observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"pool-code": {
|
||||
"property": "poolCode",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.list_pool_employees": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool list_pool_employees, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain talent-pool employees",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the list_pool_employees MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"pool-code": {
|
||||
"property": "poolCode",
|
||||
"required": true
|
||||
},
|
||||
"page": {
|
||||
"property": "currentPage",
|
||||
"required": false
|
||||
},
|
||||
"page-size": {
|
||||
"property": "pageSize",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_profile_metadata": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_profile_metadata, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain profile metadata",
|
||||
"review_reason": "Reviewed unpinned remote adapter: --work-no maps 1:1 to the workNo MCP call argument observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"work-no": {
|
||||
"property": "workNo",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.query_profile_data": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool query_profile_data, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain profile query",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the query_profile_data MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"work-no": {
|
||||
"property": "workNo",
|
||||
"required": true
|
||||
},
|
||||
"data-queries": {
|
||||
"property": "dataQueries",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_profile_label": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_profile_label, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain profile labels",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the get_profile_label MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"staff-ids": {
|
||||
"property": "staffIds",
|
||||
"required": true
|
||||
},
|
||||
"all-label": {
|
||||
"property": "allLabel",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_employee_career": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_employee_career, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain profile career",
|
||||
"review_reason": "Reviewed unpinned remote adapter: --work-no maps 1:1 to the workNo MCP call argument observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"work-no": {
|
||||
"property": "workNo",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_employee_performance": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_employee_performance, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain profile performance",
|
||||
"review_reason": "Reviewed unpinned remote adapter: --work-no maps 1:1 to the workNo MCP call argument observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"work-no": {
|
||||
"property": "workNo",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.search_employees": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool search_employees, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain search employees",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the search_employees MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"keyword": {
|
||||
"property": "keyword",
|
||||
"required": false
|
||||
},
|
||||
"dept-name": {
|
||||
"property": "deptName",
|
||||
"required": false
|
||||
},
|
||||
"position-name": {
|
||||
"property": "positionName",
|
||||
"required": false
|
||||
},
|
||||
"job-level": {
|
||||
"property": "jobLevel",
|
||||
"required": false
|
||||
},
|
||||
"pool-code": {
|
||||
"property": "poolCode",
|
||||
"required": false
|
||||
},
|
||||
"page": {
|
||||
"property": "currentPage",
|
||||
"required": false
|
||||
},
|
||||
"page-size": {
|
||||
"property": "pageSize",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.search_employees_structured": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool search_employees_structured, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"cli_path": "hrbrain search employees-structured",
|
||||
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the search_employees_structured MCP call arguments observed in internal/helpers/hrbrain.go.",
|
||||
"parameters": {
|
||||
"origin-json": {
|
||||
"property": "originJson",
|
||||
"required": true
|
||||
},
|
||||
"fields": {
|
||||
"property": "fields",
|
||||
"required": true
|
||||
},
|
||||
"order-by": {
|
||||
"property": "orderByClauses",
|
||||
"required": false
|
||||
},
|
||||
"page": {
|
||||
"property": "currentPage",
|
||||
"required": false
|
||||
},
|
||||
"page-size": {
|
||||
"property": "pageSize",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"hrbrain.get_search_fields": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"runtime_gate": "none",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_search_fields, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"channel": "open-source"
|
||||
},
|
||||
"coverage": {
|
||||
"source_tools": 603,
|
||||
"source_tools": 592,
|
||||
"matched_tools": 71
|
||||
},
|
||||
"tools": {
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source": {
|
||||
"kind": "explicit",
|
||||
"name": "dws-agent-selection/hrbrain",
|
||||
"repository": "DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
"channel": "open-source",
|
||||
"reviewed": true
|
||||
},
|
||||
"tools": {
|
||||
"hrbrain.list_talent_pools": {
|
||||
"agent_summary": "查询人才池列表,支持按名称关键词、类型、创建人、标签筛选",
|
||||
"use_when": [
|
||||
"需要列出组织中的人才池,或按名称/类型/创建人/标签筛选人才池时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"已知 poolCode 只需要单个人才池详情时改用 dws hrbrain talent-pool detail",
|
||||
"要查看人才池内人员名单时改用 dws hrbrain talent-pool employees"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain talent-pool list --page 1 --page-size 20",
|
||||
"dws hrbrain talent-pool list --keyword \"储备干部\""
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.list_talent_pools",
|
||||
"cobra-help:dws hrbrain talent-pool list",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
},
|
||||
"hrbrain.get_talent_pool_detail": {
|
||||
"agent_summary": "根据人才池编码获取人才池详细信息",
|
||||
"use_when": [
|
||||
"已知 poolCode,需要查看某个人才池的详细信息时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"尚未取得 poolCode 时先用 dws hrbrain talent-pool list 查找"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain talent-pool detail --pool-code POOL_CODE"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.get_talent_pool_detail",
|
||||
"cobra-help:dws hrbrain talent-pool detail",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
},
|
||||
"hrbrain.list_pool_employees": {
|
||||
"agent_summary": "查询指定人才池内的人员列表",
|
||||
"use_when": [
|
||||
"已知 poolCode,需要查看该人才池内的人员名单时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"尚未取得 poolCode 时先用 dws hrbrain talent-pool list 查找",
|
||||
"不限定人才池的人员搜索请改用 dws hrbrain search employees"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain talent-pool employees --pool-code POOL_CODE --page 1 --page-size 20"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.list_pool_employees",
|
||||
"cobra-help:dws hrbrain talent-pool employees",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
},
|
||||
"hrbrain.get_profile_metadata": {
|
||||
"agent_summary": "查询员工档案元数据结构,用于构造档案数据查询参数",
|
||||
"use_when": [
|
||||
"需要先了解某员工档案有哪些模块/字段,以构造 hrbrain profile query 的 --data-queries 参数时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"已知模块与字段编码,直接查询档案数据时改用 dws hrbrain profile query"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain profile metadata --work-no WORK_NO"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.get_profile_metadata",
|
||||
"cobra-help:dws hrbrain profile metadata",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
},
|
||||
"hrbrain.query_profile_data": {
|
||||
"agent_summary": "按模块批量查询员工档案数据",
|
||||
"use_when": [
|
||||
"已知模块与字段编码(通常先用 hrbrain profile metadata 获取),需要批量查询员工档案数据时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"尚不清楚可查询的模块/字段时先用 dws hrbrain profile metadata",
|
||||
"只需要职业历程或绩效记录时改用 dws hrbrain profile career / profile performance"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain profile query --work-no WORK_NO --data-queries '[{\"modelCode\":\"basic\",\"fields\":[\"name\",\"dept\"]}]'"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.query_profile_data",
|
||||
"cobra-help:dws hrbrain profile query",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
},
|
||||
"hrbrain.get_profile_label": {
|
||||
"agent_summary": "根据员工工号列表获取员工标签",
|
||||
"use_when": [
|
||||
"需要按一个或多个工号批量查看员工标签时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"要查看职业历程或绩效记录时改用 dws hrbrain profile career / profile performance"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain profile labels --staff-ids WORK_NO1,WORK_NO2"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.get_profile_label",
|
||||
"cobra-help:dws hrbrain profile labels",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
},
|
||||
"hrbrain.get_employee_career": {
|
||||
"agent_summary": "查询员工在公司内的职业历程",
|
||||
"use_when": [
|
||||
"需要查看某员工在公司内的岗位/职级变动历史时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"要查看绩效记录时改用 dws hrbrain profile performance"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain profile career --work-no WORK_NO"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.get_employee_career",
|
||||
"cobra-help:dws hrbrain profile career",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
},
|
||||
"hrbrain.get_employee_performance": {
|
||||
"agent_summary": "查询员工绩效记录",
|
||||
"use_when": [
|
||||
"需要查看某员工的历史绩效评级/记录时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"要查看职业历程时改用 dws hrbrain profile career"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain profile performance --work-no WORK_NO"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.get_employee_performance",
|
||||
"cobra-help:dws hrbrain profile performance",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
},
|
||||
"hrbrain.search_employees": {
|
||||
"agent_summary": "按关键词、部门、职务、职级、人才池等条件搜索员工",
|
||||
"use_when": [
|
||||
"需要按姓名/工号/部门/职务/职级等基础条件模糊找人时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"需要复杂组合条件(表达式)搜索时改用 dws hrbrain search employees-structured",
|
||||
"已限定某个人才池要看全部人员时改用 dws hrbrain talent-pool employees"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain search employees --keyword \"张三\" --page 1 --page-size 20",
|
||||
"dws hrbrain search employees --dept-name \"技术部\" --job-level P7"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.search_employees",
|
||||
"cobra-help:dws hrbrain search employees",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
},
|
||||
"hrbrain.search_employees_structured": {
|
||||
"agent_summary": "使用高级条件表达式(originJson)搜索员工",
|
||||
"use_when": [
|
||||
"需要使用组合过滤条件表达式搜索员工,且已通过 dws hrbrain search fields 获取有权限的字段/操作符时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只是简单关键词/部门/职级搜索时改用 dws hrbrain search employees",
|
||||
"尚未获取可用字段与操作符时先用 dws hrbrain search fields"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain search employees-structured --origin-json '{\"rules\":[{\"field\":\"name\",\"operator\":\"contains\",\"value\":\"张\"}],\"combinator\":\"and\"}' --fields '[{\"label\":\"姓名\",\"value\":\"name\"}]'"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.search_employees_structured",
|
||||
"cobra-help:dws hrbrain search employees-structured",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
},
|
||||
"hrbrain.get_search_fields": {
|
||||
"agent_summary": "获取当前操作人有权限使用的高级搜索字段与操作符列表",
|
||||
"use_when": [
|
||||
"在调用 dws hrbrain search employees-structured 之前,需要确认可用字段与操作符时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只是简单关键词搜索时改用 dws hrbrain search employees"
|
||||
],
|
||||
"examples": [
|
||||
"dws hrbrain search fields"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=hrbrain.get_search_fields",
|
||||
"cobra-help:dws hrbrain search fields",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
"hrbrain": {
|
||||
"agent_summary": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
|
||||
"use_when": [
|
||||
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"要操作通讯录/组织架构基础信息时改用 contact 产品",
|
||||
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据真实 CommandRegistry 命令分区与对应产品 Skill 审阅产品级选择边界。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:product=hrbrain",
|
||||
"Skill:skills/mono/references/products/hrbrain.md",
|
||||
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,6 @@ var cmdToProduct = map[string]string{
|
||||
"sheet": "sheet",
|
||||
"wiki": "wiki",
|
||||
"aisearch": "aisearch",
|
||||
"hrbrain": "hrbrain",
|
||||
"yida": "yida",
|
||||
// vendor extension command routing (kept here for resolveProductID)
|
||||
"unified-toolkit": "unified-toolkit",
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws hrbrain — 组织大脑(人才池、员工档案、人才搜索)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newHrbrainCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "hrbrain",
|
||||
Short: "组织大脑:人才池、员工档案与人才搜索",
|
||||
Long: `钉钉组织大脑(hrbrain)能力:人才池管理、员工档案查询、人才搜索与标签管理。
|
||||
|
||||
命令结构:
|
||||
dws hrbrain talent-pool list 人才池列表
|
||||
dws hrbrain talent-pool detail 获取人才池详情
|
||||
dws hrbrain talent-pool employees 人才池人员列表
|
||||
dws hrbrain profile metadata 查询员工档案元数据结构
|
||||
dws hrbrain profile query 按模块批量查询员工档案数据
|
||||
dws hrbrain profile labels 获取员工标签
|
||||
dws hrbrain profile career 查询员工公司内职业历程
|
||||
dws hrbrain profile performance 查询员工绩效记录
|
||||
dws hrbrain search employees 人才搜索
|
||||
dws hrbrain search employees-structured 使用高级条件搜索人员
|
||||
dws hrbrain search fields 获取高级搜索字段列表`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
// ── talent-pool: 人才池管理 ────────────────────────────────
|
||||
|
||||
talentPoolCmd := &cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE}
|
||||
|
||||
talentPoolListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "人才池列表",
|
||||
Long: `查询人才池列表,支持按名称关键词、类型、创建人、标签等条件筛选。`,
|
||||
Example: ` dws hrbrain talent-pool list --page 1 --page-size 20
|
||||
dws hrbrain talent-pool list --keyword "储备干部" --pool-type TYPE --creator USER_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
page, _ := cmd.Flags().GetInt("page")
|
||||
pageSize, _ := cmd.Flags().GetInt("page-size")
|
||||
toolArgs := map[string]any{
|
||||
"currentPage": page,
|
||||
"pageSize": pageSize,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("keyword"); v != "" {
|
||||
toolArgs["keyword"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("pool-type"); v != "" {
|
||||
toolArgs["poolType"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("creator"); v != "" {
|
||||
toolArgs["creator"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("labels"); v != "" {
|
||||
toolArgs["labels"] = parseCSVValues(v)
|
||||
}
|
||||
return callMCPTool("list_talent_pools", toolArgs)
|
||||
},
|
||||
}
|
||||
talentPoolListCmd.Flags().String("keyword", "", "人才池名称关键词 (可选)")
|
||||
talentPoolListCmd.Flags().String("pool-type", "", "人才池类型 (可选)")
|
||||
talentPoolListCmd.Flags().String("creator", "", "创建人 (可选)")
|
||||
talentPoolListCmd.Flags().String("labels", "", "标签列表,逗号分隔 (可选)")
|
||||
talentPoolListCmd.Flags().Int("page", 1, "当前页码 (默认 1)")
|
||||
talentPoolListCmd.Flags().Int("page-size", 20, "每页条数 (默认 20)")
|
||||
|
||||
talentPoolDetailCmd := &cobra.Command{
|
||||
Use: "detail",
|
||||
Short: "获取人才池详情",
|
||||
Long: `根据人才池编码获取人才池详细信息。`,
|
||||
Example: ` dws hrbrain talent-pool detail --pool-code POOL_CODE`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "pool-code"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("get_talent_pool_detail", map[string]any{
|
||||
"poolCode": mustGetFlag(cmd, "pool-code"),
|
||||
})
|
||||
},
|
||||
}
|
||||
talentPoolDetailCmd.Flags().String("pool-code", "", "人才池编码 (必填)")
|
||||
|
||||
talentPoolEmployeesCmd := &cobra.Command{
|
||||
Use: "employees",
|
||||
Short: "人才池人员列表",
|
||||
Long: `查询指定人才池内的人员列表。`,
|
||||
Example: ` dws hrbrain talent-pool employees --pool-code POOL_CODE --page 1 --page-size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "pool-code"); err != nil {
|
||||
return err
|
||||
}
|
||||
page, _ := cmd.Flags().GetInt("page")
|
||||
pageSize, _ := cmd.Flags().GetInt("page-size")
|
||||
return callMCPTool("list_pool_employees", map[string]any{
|
||||
"poolCode": mustGetFlag(cmd, "pool-code"),
|
||||
"currentPage": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
},
|
||||
}
|
||||
talentPoolEmployeesCmd.Flags().String("pool-code", "", "人才池编码 (必填)")
|
||||
talentPoolEmployeesCmd.Flags().Int("page", 1, "当前页码 (默认 1)")
|
||||
talentPoolEmployeesCmd.Flags().Int("page-size", 20, "每页条数 (默认 20)")
|
||||
|
||||
talentPoolCmd.AddCommand(talentPoolListCmd, talentPoolDetailCmd, talentPoolEmployeesCmd)
|
||||
|
||||
// ── profile: 员工档案管理 ──────────────────────────────────
|
||||
|
||||
profileCmd := &cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE}
|
||||
|
||||
profileMetadataCmd := &cobra.Command{
|
||||
Use: "metadata",
|
||||
Short: "查询员工档案元数据结构",
|
||||
Long: `查询指定员工档案的元数据结构,用于构造 query_profile_data 的 dataQueries 参数。`,
|
||||
Example: ` dws hrbrain profile metadata --work-no WORK_NO`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "work-no"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("get_profile_metadata", map[string]any{
|
||||
"workNo": mustGetFlag(cmd, "work-no"),
|
||||
})
|
||||
},
|
||||
}
|
||||
profileMetadataCmd.Flags().String("work-no", "", "员工工号 (必填)")
|
||||
|
||||
profileQueryCmd := &cobra.Command{
|
||||
Use: "query",
|
||||
Short: "按模块批量查询员工档案数据",
|
||||
Long: `按模块维度批量查询员工档案数据。
|
||||
--data-queries 为 JSON 数组,每个元素包含:
|
||||
modelCode — 档案模块编码
|
||||
fields — 要查询的字段编码列表`,
|
||||
Example: ` dws hrbrain profile query --work-no WORK_NO --data-queries '[{"modelCode":"basic","fields":["name","dept"]}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "work-no", "data-queries"); err != nil {
|
||||
return err
|
||||
}
|
||||
var queries []any
|
||||
if err := json.Unmarshal([]byte(mustGetFlag(cmd, "data-queries")), &queries); err != nil {
|
||||
return fmt.Errorf("--data-queries must be a valid JSON array: %w", err)
|
||||
}
|
||||
if len(queries) == 0 {
|
||||
return fmt.Errorf("--data-queries must be a non-empty JSON array")
|
||||
}
|
||||
return callMCPTool("query_profile_data", map[string]any{
|
||||
"workNo": mustGetFlag(cmd, "work-no"),
|
||||
"dataQueries": queries,
|
||||
})
|
||||
},
|
||||
}
|
||||
profileQueryCmd.Flags().String("work-no", "", "目标员工工号 (必填)")
|
||||
profileQueryCmd.Flags().String("data-queries", "", "按模块查询的条件列表 JSON 数组 (必填)")
|
||||
|
||||
profileLabelsCmd := &cobra.Command{
|
||||
Use: "labels",
|
||||
Short: "获取员工标签",
|
||||
Long: `根据员工工号列表获取员工标签。`,
|
||||
Example: ` dws hrbrain profile labels --staff-ids WORK_NO1,WORK_NO2 --all-label`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "staff-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"staffIds": parseCSVValues(mustGetFlag(cmd, "staff-ids")),
|
||||
}
|
||||
if cmd.Flags().Changed("all-label") {
|
||||
v, _ := cmd.Flags().GetBool("all-label")
|
||||
toolArgs["allLabel"] = v
|
||||
}
|
||||
return callMCPTool("get_profile_label", toolArgs)
|
||||
},
|
||||
}
|
||||
profileLabelsCmd.Flags().String("staff-ids", "", "员工工号列表,逗号分隔 (必填)")
|
||||
profileLabelsCmd.Flags().Bool("all-label", false, "是否所有标签 (可选)")
|
||||
|
||||
profileCareerCmd := &cobra.Command{
|
||||
Use: "career",
|
||||
Short: "查询员工公司内职业历程",
|
||||
Example: ` dws hrbrain profile career --work-no WORK_NO`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "work-no"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("get_employee_career", map[string]any{
|
||||
"workNo": mustGetFlag(cmd, "work-no"),
|
||||
})
|
||||
},
|
||||
}
|
||||
profileCareerCmd.Flags().String("work-no", "", "员工工号 (必填)")
|
||||
|
||||
profilePerformanceCmd := &cobra.Command{
|
||||
Use: "performance",
|
||||
Short: "查询员工绩效记录",
|
||||
Example: ` dws hrbrain profile performance --work-no WORK_NO`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "work-no"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("get_employee_performance", map[string]any{
|
||||
"workNo": mustGetFlag(cmd, "work-no"),
|
||||
})
|
||||
},
|
||||
}
|
||||
profilePerformanceCmd.Flags().String("work-no", "", "员工工号 (必填)")
|
||||
|
||||
profileCmd.AddCommand(profileMetadataCmd, profileQueryCmd, profileLabelsCmd, profileCareerCmd, profilePerformanceCmd)
|
||||
|
||||
// ── search: 人才搜索 ─────────────────────────────────────
|
||||
|
||||
searchCmd := &cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE}
|
||||
|
||||
employeeSearchCmd := &cobra.Command{
|
||||
Use: "employees",
|
||||
Short: "人才搜索",
|
||||
Long: `按关键词、部门、职务、职级、人才池等条件搜索人员。`,
|
||||
Example: ` dws hrbrain search employees --keyword "张三" --page 1 --page-size 20
|
||||
dws hrbrain search employees --dept-name "技术部" --job-level P7 --pool-code POOL_CODE`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
page, _ := cmd.Flags().GetInt("page")
|
||||
pageSize, _ := cmd.Flags().GetInt("page-size")
|
||||
toolArgs := map[string]any{
|
||||
"currentPage": page,
|
||||
"pageSize": pageSize,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("keyword"); v != "" {
|
||||
toolArgs["keyword"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("dept-name"); v != "" {
|
||||
toolArgs["deptName"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("position-name"); v != "" {
|
||||
toolArgs["positionName"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("job-level"); v != "" {
|
||||
toolArgs["jobLevel"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("pool-code"); v != "" {
|
||||
toolArgs["poolCode"] = v
|
||||
}
|
||||
return callMCPTool("search_employees", toolArgs)
|
||||
},
|
||||
}
|
||||
employeeSearchCmd.Flags().String("keyword", "", "全文搜索关键词(姓名/工号等)(可选)")
|
||||
employeeSearchCmd.Flags().String("dept-name", "", "部门名称 (可选)")
|
||||
employeeSearchCmd.Flags().String("position-name", "", "职务名称 (可选)")
|
||||
employeeSearchCmd.Flags().String("job-level", "", "职级 (可选)")
|
||||
employeeSearchCmd.Flags().String("pool-code", "", "限定人才池编码 (可选)")
|
||||
employeeSearchCmd.Flags().Int("page", 1, "当前页码 (默认 1)")
|
||||
employeeSearchCmd.Flags().Int("page-size", 20, "每页条数 (默认 20)")
|
||||
|
||||
employeeSearchStructuredCmd := &cobra.Command{
|
||||
Use: "employees-structured",
|
||||
Short: "使用高级条件搜索人员",
|
||||
Long: `使用高级条件(originJson 表达式)搜索人员。
|
||||
建议先调用 "dws hrbrain search fields" 获取有权限的字段与操作符列表。
|
||||
--origin-json 为 JSON 字符串,例如:
|
||||
{"rules":[{"field":"name","operator":"contains","value":"张"}],"combinator":"and"}
|
||||
--fields 为 JSON 数组,例如:
|
||||
[{"label":"姓名","value":"name"}]
|
||||
--order-by 为逗号分隔的排序字段列表 (可选)`,
|
||||
Example: ` dws hrbrain search employees-structured --origin-json '{"rules":[{"field":"name","operator":"contains","value":"张"}],"combinator":"and"}' --fields '[{"label":"姓名","value":"name"}]' --page 1 --page-size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "origin-json", "fields"); err != nil {
|
||||
return err
|
||||
}
|
||||
page, _ := cmd.Flags().GetInt("page")
|
||||
pageSize, _ := cmd.Flags().GetInt("page-size")
|
||||
originJSON := mustGetFlag(cmd, "origin-json")
|
||||
var originObj map[string]any
|
||||
if err := json.Unmarshal([]byte(originJSON), &originObj); err != nil {
|
||||
return fmt.Errorf("--origin-json must be a valid JSON object: %w", err)
|
||||
}
|
||||
var fields []any
|
||||
if err := json.Unmarshal([]byte(mustGetFlag(cmd, "fields")), &fields); err != nil {
|
||||
return fmt.Errorf("--fields must be a valid JSON array: %w", err)
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return fmt.Errorf("--fields must be a non-empty JSON array")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"originJson": originJSON,
|
||||
"currentPage": page,
|
||||
"pageSize": pageSize,
|
||||
"fields": fields,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
|
||||
toolArgs["orderByClauses"] = parseCSVValues(v)
|
||||
}
|
||||
return callMCPTool("search_employees_structured", toolArgs)
|
||||
},
|
||||
}
|
||||
employeeSearchStructuredCmd.Flags().String("origin-json", "", "搜索条件 JSON 表达式 (必填)")
|
||||
employeeSearchStructuredCmd.Flags().Int("page", 1, "当前页码 (默认 1)")
|
||||
employeeSearchStructuredCmd.Flags().Int("page-size", 20, "每页条数 (默认 20)")
|
||||
employeeSearchStructuredCmd.Flags().String("order-by", "", "排序字段列表,逗号分隔 (可选)")
|
||||
employeeSearchStructuredCmd.Flags().String("fields", "", "返回列定义 JSON 数组 (必填)")
|
||||
|
||||
searchFieldsCmd := &cobra.Command{
|
||||
Use: "fields",
|
||||
Short: "获取高级搜索字段列表",
|
||||
Long: `获取当前操作人有权限使用的高级搜索字段列表,用于构造 employees-structured 的参数。`,
|
||||
Example: ` dws hrbrain search fields`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return callMCPTool("get_search_fields", nil)
|
||||
},
|
||||
}
|
||||
|
||||
searchCmd.AddCommand(employeeSearchCmd, employeeSearchStructuredCmd, searchFieldsCmd)
|
||||
|
||||
root.AddCommand(talentPoolCmd, profileCmd, searchCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// executeHrbrainCommand runs the given hrbrain command tree with args, discarding
|
||||
// output, and returns the error (if any) from RunE.
|
||||
func executeHrbrainCommand(t *testing.T, root *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws", "hrbrain"}, args...)
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
// SetArgs must receive a non-nil slice: when args is nil (zero variadic
|
||||
// call), cobra falls back to os.Args[1:] internally, which would wrongly
|
||||
// feed the literal "hrbrain" token back in as a bogus positional arg.
|
||||
root.SetArgs(append([]string{}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestHrbrainTalentPoolList(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"talent-pool", "list",
|
||||
); err != nil {
|
||||
t.Fatalf("list without optional flags: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"talent-pool", "list",
|
||||
"--page", "1", "--page-size", "20",
|
||||
"--keyword", "储备干部", "--pool-type", "TYPE", "--creator", "USER_ID",
|
||||
"--labels", "a,b,c",
|
||||
); err != nil {
|
||||
t.Fatalf("list with optional flags: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainTalentPoolDetail(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"talent-pool", "detail", "--pool-code", "POOL_CODE",
|
||||
); err != nil {
|
||||
t.Fatalf("detail with pool-code: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"talent-pool", "detail",
|
||||
); err == nil {
|
||||
t.Fatal("detail without pool-code should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainTalentPoolEmployees(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"talent-pool", "employees",
|
||||
"--pool-code", "POOL_CODE", "--page", "1", "--page-size", "20",
|
||||
); err != nil {
|
||||
t.Fatalf("employees with pool-code: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"talent-pool", "employees",
|
||||
); err == nil {
|
||||
t.Fatal("employees without pool-code should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainProfileMetadata(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "metadata", "--work-no", "WORK_NO",
|
||||
); err != nil {
|
||||
t.Fatalf("metadata with work-no: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "metadata",
|
||||
); err == nil {
|
||||
t.Fatal("metadata without work-no should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainProfileQuery(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "query",
|
||||
"--work-no", "WORK_NO",
|
||||
"--data-queries", `[{"modelCode":"basic","fields":["name","dept"]}]`,
|
||||
); err != nil {
|
||||
t.Fatalf("query with valid data-queries: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "query",
|
||||
"--work-no", "WORK_NO",
|
||||
"--data-queries", `not-json`,
|
||||
); err == nil {
|
||||
t.Fatal("query with invalid data-queries JSON should error")
|
||||
}
|
||||
|
||||
// An empty JSON array is syntactically valid but queries nothing, so it
|
||||
// must be rejected before dispatch.
|
||||
emptyDataQueriesCaller := &scriptedToolCaller{dry: true}
|
||||
installScriptedCaller(t, emptyDataQueriesCaller)
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "query",
|
||||
"--work-no", "WORK_NO",
|
||||
"--data-queries", `[]`,
|
||||
); err == nil {
|
||||
t.Fatal("query with empty data-queries array should error")
|
||||
}
|
||||
if emptyDataQueriesCaller.calls != 0 {
|
||||
t.Fatalf("query with empty data-queries must not call MCP, got %d call(s)", emptyDataQueriesCaller.calls)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "query", "--work-no", "WORK_NO",
|
||||
); err == nil {
|
||||
t.Fatal("query without data-queries should error")
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "query", "--data-queries", `[]`,
|
||||
); err == nil {
|
||||
t.Fatal("query without work-no should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainProfileLabels(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "labels", "--staff-ids", "WORK_NO1,WORK_NO2", "--all-label",
|
||||
); err != nil {
|
||||
t.Fatalf("labels with staff-ids and all-label: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "labels", "--staff-ids", "WORK_NO1",
|
||||
); err != nil {
|
||||
t.Fatalf("labels without all-label: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "labels",
|
||||
); err == nil {
|
||||
t.Fatal("labels without staff-ids should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainProfileCareer(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "career", "--work-no", "WORK_NO",
|
||||
); err != nil {
|
||||
t.Fatalf("career with work-no: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "career",
|
||||
); err == nil {
|
||||
t.Fatal("career without work-no should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainProfilePerformance(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "performance", "--work-no", "WORK_NO",
|
||||
); err != nil {
|
||||
t.Fatalf("performance with work-no: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"profile", "performance",
|
||||
); err == nil {
|
||||
t.Fatal("performance without work-no should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainSearchEmployees(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"search", "employees",
|
||||
); err != nil {
|
||||
t.Fatalf("search employees without optional flags: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"search", "employees",
|
||||
"--keyword", "张三", "--dept-name", "技术部", "--position-name", "工程师",
|
||||
"--job-level", "P7", "--pool-code", "POOL_CODE",
|
||||
"--page", "1", "--page-size", "20",
|
||||
); err != nil {
|
||||
t.Fatalf("search employees with all optional flags: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainSearchEmployeesStructured(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"search", "employees-structured",
|
||||
"--origin-json", `{"rules":[{"field":"name","operator":"contains","value":"张"}],"combinator":"and"}`,
|
||||
"--fields", `[{"label":"姓名","value":"name"}]`,
|
||||
"--order-by", "name,dept",
|
||||
"--page", "1", "--page-size", "20",
|
||||
); err != nil {
|
||||
t.Fatalf("search employees-structured with valid args: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"search", "employees-structured",
|
||||
"--origin-json", `{}`,
|
||||
"--fields", `not-json`,
|
||||
); err == nil {
|
||||
t.Fatal("search employees-structured with invalid fields JSON should error")
|
||||
}
|
||||
|
||||
invalidOriginJSONCaller := &scriptedToolCaller{dry: true}
|
||||
installScriptedCaller(t, invalidOriginJSONCaller)
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"search", "employees-structured",
|
||||
"--origin-json", `not-json`,
|
||||
"--fields", `[]`,
|
||||
); err == nil {
|
||||
t.Fatal("search employees-structured with invalid origin-json JSON should error")
|
||||
}
|
||||
if invalidOriginJSONCaller.calls != 0 {
|
||||
t.Fatalf("search employees-structured with invalid origin-json must not call MCP, got %d call(s)", invalidOriginJSONCaller.calls)
|
||||
}
|
||||
|
||||
// A bare JSON scalar (e.g. a number) is valid JSON per json.Valid, but it
|
||||
// is not a JSON object, so it must still be rejected before dispatch.
|
||||
nonObjectOriginJSONCaller := &scriptedToolCaller{dry: true}
|
||||
installScriptedCaller(t, nonObjectOriginJSONCaller)
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"search", "employees-structured",
|
||||
"--origin-json", `111`,
|
||||
"--fields", `[]`,
|
||||
); err == nil {
|
||||
t.Fatal("search employees-structured with non-object origin-json JSON should error")
|
||||
}
|
||||
if nonObjectOriginJSONCaller.calls != 0 {
|
||||
t.Fatalf("search employees-structured with non-object origin-json must not call MCP, got %d call(s)", nonObjectOriginJSONCaller.calls)
|
||||
}
|
||||
|
||||
// An empty --fields array is syntactically valid but selects no columns,
|
||||
// so it must be rejected before dispatch.
|
||||
emptyFieldsCaller := &scriptedToolCaller{dry: true}
|
||||
installScriptedCaller(t, emptyFieldsCaller)
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"search", "employees-structured",
|
||||
"--origin-json", `{"rules":[],"combinator":"and"}`,
|
||||
"--fields", `[]`,
|
||||
); err == nil {
|
||||
t.Fatal("search employees-structured with empty fields array should error")
|
||||
}
|
||||
if emptyFieldsCaller.calls != 0 {
|
||||
t.Fatalf("search employees-structured with empty fields must not call MCP, got %d call(s)", emptyFieldsCaller.calls)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"search", "employees-structured", "--fields", `[]`,
|
||||
); err == nil {
|
||||
t.Fatal("search employees-structured without origin-json should error")
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"search", "employees-structured", "--origin-json", `{}`,
|
||||
); err == nil {
|
||||
t.Fatal("search employees-structured without fields should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainSearchFields(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(),
|
||||
"search", "fields",
|
||||
); err != nil {
|
||||
t.Fatalf("search fields: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHrbrainGroupCommandsWiring(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
|
||||
root := newHrbrainCommand()
|
||||
if err := executeHrbrainCommand(t, root); err != nil {
|
||||
t.Fatalf("hrbrain root with no args should show help: %v", err)
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(), "talent-pool"); err != nil {
|
||||
t.Fatalf("talent-pool group with no args should show help: %v", err)
|
||||
}
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(), "talent-pool", "bogus"); err == nil {
|
||||
t.Fatal("talent-pool group with unknown subcommand should error")
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(), "profile"); err != nil {
|
||||
t.Fatalf("profile group with no args should show help: %v", err)
|
||||
}
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(), "profile", "bogus"); err == nil {
|
||||
t.Fatal("profile group with unknown subcommand should error")
|
||||
}
|
||||
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(), "search"); err != nil {
|
||||
t.Fatalf("search group with no args should show help: %v", err)
|
||||
}
|
||||
if err := executeHrbrainCommand(t, newHrbrainCommand(), "search", "bogus"); err == nil {
|
||||
t.Fatal("search group with unknown subcommand should error")
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var markdownUploadStat = os.Stat
|
||||
// markdownStatUpload stats the prepared upload file. Tests override it to
|
||||
// deterministically simulate the file disappearing between write and stat.
|
||||
var markdownStatUpload = os.Stat
|
||||
|
||||
func newMarkdownCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
@@ -259,7 +261,7 @@ func runMarkdownCreate(cmd *cobra.Command, _ []string) error {
|
||||
if !hasMarkdownExtension(nameFlag) {
|
||||
return fmt.Errorf("--name 必须以 .md 结尾,当前: %s", nameFlag)
|
||||
}
|
||||
info, err := markdownUploadStat(uploadPath)
|
||||
info, err := markdownStatUpload(uploadPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取上传文件失败: %w", err)
|
||||
}
|
||||
@@ -423,7 +425,7 @@ func runMarkdownOverwrite(cmd *cobra.Command, _ []string) error {
|
||||
if !hasMarkdownExtension(nameFlag) {
|
||||
return fmt.Errorf("--name 必须以 .md 结尾,当前: %s", nameFlag)
|
||||
}
|
||||
info, err := markdownUploadStat(uploadPath)
|
||||
info, err := markdownStatUpload(uploadPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取上传文件失败: %w", err)
|
||||
}
|
||||
@@ -565,7 +567,7 @@ func runMarkdownPatch(cmd *cobra.Command, _ []string) error {
|
||||
if err := os.WriteFile(uploadPath, []byte(newContent), 0o600); err != nil {
|
||||
return fmt.Errorf("写入临时文件失败: %w", err)
|
||||
}
|
||||
info, err := markdownUploadStat(uploadPath)
|
||||
info, err := markdownStatUpload(uploadPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取临时文件失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -24,58 +24,20 @@ func (markdownCIFailingReader) Read([]byte) (int, error) {
|
||||
return 0, errors.New("forced read failure")
|
||||
}
|
||||
|
||||
func installMarkdownCITempFileDisappearance(t *testing.T) {
|
||||
// installMarkdownStatUploadRemover overrides the markdownStatUpload seam so
|
||||
// the prepared upload file is deterministically removed before stat runs,
|
||||
// simulating the file disappearing between write and stat without racing a
|
||||
// background goroutine.
|
||||
func installMarkdownStatUploadRemover(t *testing.T) {
|
||||
t.Helper()
|
||||
original := markdownUploadStat
|
||||
markdownUploadStat = func(path string) (os.FileInfo, error) {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return nil, err
|
||||
original := markdownStatUpload
|
||||
markdownStatUpload = func(name string) (os.FileInfo, error) {
|
||||
if err := os.Remove(name); err != nil && !os.IsNotExist(err) {
|
||||
t.Errorf("removing upload file %q: %v", name, err)
|
||||
}
|
||||
return os.Stat(path)
|
||||
return os.Stat(name)
|
||||
}
|
||||
t.Cleanup(func() { markdownUploadStat = original })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownUploadStatFailures(t *testing.T) {
|
||||
t.Run("create", func(t *testing.T) {
|
||||
installMarkdownDriveDeps(t, &markdownDriveCaller{format: "json"})
|
||||
installMarkdownCITempFileDisappearance(t)
|
||||
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
|
||||
"markdown", "create", "--name", "a.md", "--content", "body")
|
||||
if err == nil || !strings.Contains(err.Error(), "读取上传文件失败") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("overwrite", func(t *testing.T) {
|
||||
installMarkdownDriveDeps(t, &markdownDriveCaller{format: "json"})
|
||||
installMarkdownCITempFileDisappearance(t)
|
||||
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
|
||||
"markdown", "overwrite", "--node", "node-1", "--space-id", "space-1",
|
||||
"--name", "a.md", "--content", "body")
|
||||
if err == nil || !strings.Contains(err.Error(), "读取上传文件失败") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("patch", func(t *testing.T) {
|
||||
caller := &markdownDriveCaller{
|
||||
format: "json",
|
||||
steps: []markdownDriveStep{
|
||||
{text: `{"downloadUrl":"https://download.test/current.md","fileName":"current.md"}`},
|
||||
{text: `{"fileName":"current.md"}`},
|
||||
},
|
||||
}
|
||||
installMarkdownDriveDeps(t, caller)
|
||||
installMarkdownHTTPGet(t, "old")
|
||||
installMarkdownCITempFileDisappearance(t)
|
||||
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
|
||||
"markdown", "patch", "--node", "node-1", "--space-id", "space-1",
|
||||
"--pattern", "old", "--content", "new", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "读取临时文件失败") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Cleanup(func() { markdownStatUpload = original })
|
||||
}
|
||||
|
||||
func TestMarkdownCICoverageFetchAndOutputPaths(t *testing.T) {
|
||||
@@ -198,7 +160,7 @@ func TestMarkdownCICoverageCreateEdges(t *testing.T) {
|
||||
|
||||
t.Run("temporary file disappears before stat", func(t *testing.T) {
|
||||
installMarkdownDriveDeps(t, &markdownDriveCaller{format: "json"})
|
||||
installMarkdownCITempFileDisappearance(t)
|
||||
installMarkdownStatUploadRemover(t)
|
||||
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
|
||||
"markdown", "create", "--name", "a.md", "--content", "body")
|
||||
if err == nil || !strings.Contains(err.Error(), "读取上传文件失败") {
|
||||
@@ -275,7 +237,7 @@ func TestMarkdownCICoverageOverwriteEdges(t *testing.T) {
|
||||
|
||||
t.Run("temporary file disappears before stat", func(t *testing.T) {
|
||||
installMarkdownDriveDeps(t, &markdownDriveCaller{format: "json"})
|
||||
installMarkdownCITempFileDisappearance(t)
|
||||
installMarkdownStatUploadRemover(t)
|
||||
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
|
||||
"markdown", "overwrite", "--node", "node-1", "--space-id", "space-1",
|
||||
"--name", "a.md", "--content", "body")
|
||||
@@ -452,7 +414,7 @@ func TestMarkdownCICoveragePatchEdges(t *testing.T) {
|
||||
}
|
||||
installMarkdownDriveDeps(t, caller)
|
||||
installMarkdownHTTPGet(t, "old")
|
||||
installMarkdownCITempFileDisappearance(t)
|
||||
installMarkdownStatUploadRemover(t)
|
||||
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
|
||||
"markdown", "patch", "--node", "node-1", "--space-id", "space-1",
|
||||
"--pattern", "old", "--content", "new", "--yes")
|
||||
|
||||
@@ -22,11 +22,9 @@ type scriptedToolCaller struct {
|
||||
index int
|
||||
format string
|
||||
dry bool
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *scriptedToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls++
|
||||
if len(c.steps) == 0 {
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ func init() {
|
||||
{"ding", newDingCommand},
|
||||
{"doc", newDocCommand},
|
||||
{"drive", newDriveCommand},
|
||||
{"hrbrain", newHrbrainCommand},
|
||||
{"live", newLiveCommand},
|
||||
{"mail", newMailCommand},
|
||||
{"markdown", newMarkdownCommand},
|
||||
|
||||
@@ -455,12 +455,6 @@ func searchClassProject(data map[string]any) []map[string]any {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// get_class_list wraps each shift's identity/label under shiftVO
|
||||
// (item = {"shiftVO": {id, name, ...}}); unwrap it first, otherwise every
|
||||
// row projects empty and the whole command silently returns nothing.
|
||||
if vo, ok := m["shiftVO"].(map[string]any); ok {
|
||||
m = vo
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok {
|
||||
row["classId"] = v
|
||||
@@ -674,21 +668,13 @@ var SearchAdjustmentRule = shortcut.Shortcut{
|
||||
// across candidate keys, so an unknown/empty shape yields an empty list rather
|
||||
// than a crash or fabricated data.
|
||||
func searchRuleProject(data map[string]any) []map[string]any {
|
||||
// get_overtime_rule nests the list under result.atRuleList and
|
||||
// get_adjustment_rule under result.adjustmentList; both wrap each rule's
|
||||
// identity/label under entityVO. Probe those container keys AND unwrap
|
||||
// entityVO, otherwise +search-overtime-rule / +search-adjustment-rule
|
||||
// silently return empty despite the backend returning rules.
|
||||
raw := attendanceResolveList(data, "atRuleList", "adjustmentList", "ruleList", "rules", "list", "items")
|
||||
raw := attendanceResolveList(data, "ruleList", "rules", "list", "items")
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if vo, ok := m["entityVO"].(map[string]any); ok {
|
||||
m = vo
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok {
|
||||
row["ruleId"] = v
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package attendance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSearchClassProjectShiftVOShape guards against projection-data-loss:
|
||||
// get_class_list nests the list under result.items and wraps each shift's
|
||||
// identity under shiftVO. The projection must unwrap shiftVO or +search-class
|
||||
// silently returns empty despite the backend returning shifts.
|
||||
func TestSearchClassProjectShiftVOShape(t *testing.T) {
|
||||
const raw = `{"result":{"items":[
|
||||
{"shiftVO":{"id":957395083,"name":"default shift"}},
|
||||
{"shiftVO":{"id":957395084,"name":"morning shift"}}
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
got := searchClassProject(data)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: 2 shifts in backend, projection returned %d (%v)", len(got), got)
|
||||
}
|
||||
if got[0]["name"] == nil || got[0]["classId"] == nil {
|
||||
t.Fatalf("shiftVO fields not unwrapped: %v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList)
|
||||
// and get_adjustment_rule (result.adjustmentList), both wrapping the rule under
|
||||
// entityVO. The resolver must probe those keys and unwrap entityVO or
|
||||
// +search-overtime-rule / +search-adjustment-rule silently return empty.
|
||||
func TestSearchRuleProjectEntityVOShape(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, key string
|
||||
}{
|
||||
{"overtime", "atRuleList"},
|
||||
{"adjustment", "adjustmentList"},
|
||||
} {
|
||||
raw := `{"result":{"` + tc.key + `":[
|
||||
{"entityVO":{"id":11,"name":"weekday overtime"},"permissionVO":{}},
|
||||
{"entityVO":{"id":12,"name":"holiday overtime"},"permissionVO":{}}
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err)
|
||||
}
|
||||
got := searchRuleProject(data)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("[%s] lower/upper mismatch: result.%s has 2 entries, projection returned %d", tc.name, tc.key, len(got))
|
||||
}
|
||||
if got[0]["ruleId"] == nil || got[0]["name"] == nil {
|
||||
t.Fatalf("[%s] entityVO fields not unwrapped: %v", tc.name, got[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,10 +494,7 @@ func roomGroupsProject(data map[string]any) []map[string]any {
|
||||
// roomGroupsContainer locates the group slice across candidate wrapper keys,
|
||||
// unwrapping one nested object layer (e.g. result.list) when needed.
|
||||
func roomGroupsContainer(data map[string]any) []any {
|
||||
// list_meeting_room_groups nests the groups under result.groupList;
|
||||
// "groupList" MUST be probed or +room-groups silently returns empty despite
|
||||
// the backend returning meeting-room groups.
|
||||
keys := []string{"result", "data", "list", "items", "groupList", "groups"}
|
||||
keys := []string{"result", "data", "list", "items", "groups"}
|
||||
for _, k := range keys {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRoomGroupsProjectGroupListShape guards against projection-data-loss:
|
||||
// list_meeting_room_groups nests the groups under result.groupList; the resolver
|
||||
// must probe "groupList" or +room-groups silently returns empty despite the
|
||||
// backend returning meeting-room groups.
|
||||
func TestRoomGroupsProjectGroupListShape(t *testing.T) {
|
||||
const raw = `{"result":{"groupList":[
|
||||
{"groupId":"g1","groupName":"north rooms"},
|
||||
{"groupId":"g2","groupName":"south rooms"}
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if got := roomGroupsProject(data); len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.groupList has 2 entries, projection returned %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -413,12 +413,7 @@ func chatGroupResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
// "roles" is probed too: chatRoleListProject reuses this resolver and
|
||||
// list_custom_group_roles nests its list under result.roles — without it
|
||||
// +chat-role-list silently returns empty despite the group having roles.
|
||||
// Group listings key on groups/conversations, which are probed first, so
|
||||
// adding roles is harmless to them.
|
||||
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations", "roles"} {
|
||||
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
@@ -427,7 +422,7 @@ func chatGroupResolveList(data map[string]any) []any {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "groups", "conversations", "roles", "result", "data"} {
|
||||
for _, ik := range []string{"list", "items", "groups", "conversations", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChatRoleListProjectRolesShape guards against projection-data-loss:
|
||||
// list_custom_group_roles nests the roles under result.roles; the shared
|
||||
// resolver must probe "roles" or +chat-role-list silently returns empty despite
|
||||
// the group having custom roles.
|
||||
func TestChatRoleListProjectRolesShape(t *testing.T) {
|
||||
const raw = `{"result":{"roles":[
|
||||
{"openRoleId":"r1","name":"CTO"},
|
||||
{"openRoleId":"r2","name":"CEO"}
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
got := chatRoleListProject(data)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.roles has 2 entries, projection returned %d (%v)", len(got), got)
|
||||
}
|
||||
if got[0]["openRoleId"] == nil || got[0]["name"] == nil {
|
||||
t.Fatalf("role fields missing: %v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatGroupResolveListStillHandlesGroups ensures adding "roles" did not
|
||||
// disturb the group-listing shape the resolver is shared with.
|
||||
func TestChatGroupResolveListStillHandlesGroups(t *testing.T) {
|
||||
const raw = `{"result":{"groups":[{"openConversationId":"c1","title":"project group"}]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if got := chatGroupResolveList(data); len(got) != 1 {
|
||||
t.Fatalf("group shape regressed: want 1, got %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -214,13 +214,13 @@ func listRolesResolveList(data map[string]any) []any {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return listRolesFlattenGroups(arr)
|
||||
return arr
|
||||
}
|
||||
// container may itself wrap the list one level deeper
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "labels", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return listRolesFlattenGroups(arr)
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,42 +228,6 @@ func listRolesResolveList(data map[string]any) []any {
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// listRolesFlattenGroups flattens the grouped get_org_labels response into a
|
||||
// flat list of label objects. get_org_labels returns result[] as label GROUPS
|
||||
// ({groupName, labels[]}), nesting the actual roles one level under each group's
|
||||
// labels[]. A naive top-level scan would hand back the group wrappers — which
|
||||
// carry no labelId/name — so every row would silently project to empty. When
|
||||
// the array is already a flat label list (no per-group labels[]), it is returned
|
||||
// unchanged so the projection tolerates both response shapes.
|
||||
func listRolesFlattenGroups(arr []any) []any {
|
||||
flattened := make([]any, 0, len(arr))
|
||||
sawGroup := false
|
||||
for _, item := range arr {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
flattened = append(flattened, item)
|
||||
continue
|
||||
}
|
||||
var nested []any
|
||||
for _, gk := range []string{"labels", "subLabels"} {
|
||||
if inner, ok := m[gk].([]any); ok {
|
||||
nested = inner
|
||||
break
|
||||
}
|
||||
}
|
||||
if nested != nil {
|
||||
sawGroup = true
|
||||
flattened = append(flattened, nested...)
|
||||
continue
|
||||
}
|
||||
flattened = append(flattened, item)
|
||||
}
|
||||
if sawGroup {
|
||||
return flattened
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// listRolesFirst returns the first present candidate key's value.
|
||||
func listRolesFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
@@ -327,12 +291,6 @@ func memberListProject(data map[string]any) []map[string]any {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// get_dept_members_by_deptId wraps each member under userInfo
|
||||
// (deptUserList item = {"userInfo": {userId, name, ...}}); unwrap it or
|
||||
// every row projects empty and +list-dept-members silently returns none.
|
||||
if ui, ok := m["userInfo"].(map[string]any); ok {
|
||||
m = ui
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v := memberListFirst(m, "userId", "user_id", "userid", "id"); v != nil {
|
||||
row["userId"] = v
|
||||
@@ -348,14 +306,9 @@ func memberListProject(data map[string]any) []map[string]any {
|
||||
}
|
||||
|
||||
// memberListFindList returns the first slice found under the common list
|
||||
// container keys, or nil when none is present. get_dept_members_by_deptId nests
|
||||
// its members under deptUserList, so that key must be probed too.
|
||||
// container keys, or nil when none is present.
|
||||
func memberListFindList(m map[string]any) []any {
|
||||
// get_dept_members_by_deptId nests under deptUserList and
|
||||
// get_label_members_by_labelId under labelUserList; both must be probed (and
|
||||
// each item unwrapped from userInfo above) or +list-dept-members /
|
||||
// +list-role-members silently return empty despite having members.
|
||||
for _, k := range []string{"result", "data", "list", "items", "members", "userList", "deptUserList", "labelUserList"} {
|
||||
for _, k := range []string{"result", "data", "list", "items", "members", "userList"} {
|
||||
if arr, ok := m[k].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestListRolesProjectGroupedShape guards against the projection-data-loss class:
|
||||
// get_org_labels returns result[] as label GROUPS with the real roles nested one
|
||||
// level under each group's labels[]. The projection MUST descend into those
|
||||
// nested labels[] rather than treating group wrappers as roles — otherwise it
|
||||
// silently returns empty (exit 0, no error envelope) while the backend has data.
|
||||
func TestListRolesProjectGroupedShape(t *testing.T) {
|
||||
// Faithful get_org_labels shape (as returned by the backend / leaf command).
|
||||
const raw = `{"result":[
|
||||
{"groupName":"default","labels":[
|
||||
{"labelId":101,"name":"roleA"},
|
||||
{"labelId":102,"name":"roleB"},
|
||||
{"labelId":103,"name":"roleC"}]},
|
||||
{"groupName":"job","labels":[
|
||||
{"labelId":104,"name":"roleD"},
|
||||
{"labelId":105,"name":"roleE"}]}
|
||||
]}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
|
||||
roles := listRolesProject(data)
|
||||
if len(roles) != 5 {
|
||||
t.Fatalf("lower/upper mismatch: 5 roles in backend, projection returned %d (roles=%v)", len(roles), roles)
|
||||
}
|
||||
for _, r := range roles {
|
||||
if r["labelId"] == nil || r["labelName"] == nil {
|
||||
t.Fatalf("projected role missing labelId/labelName: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListRolesProjectNestedWrapperAndNonMap covers the nested map-wrapper branch
|
||||
// ({result:{labels:[...]}}) and a non-map array element, which must be passed
|
||||
// through rather than dropped.
|
||||
func TestListRolesProjectNestedWrapperAndNonMap(t *testing.T) {
|
||||
const raw = `{"result":{"labels":[
|
||||
{"groupName":"default","labels":[{"labelId":1,"name":"roleA"}]},
|
||||
"junk"
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if roles := listRolesProject(data); len(roles) != 1 {
|
||||
t.Fatalf("nested wrapper + non-map: want 1 role, got %d (%v)", len(roles), roles)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListRolesProjectFlatShape ensures the flatten step is a no-op when the
|
||||
// response is already a flat label list (response-shape drift tolerance).
|
||||
func TestListRolesProjectFlatShape(t *testing.T) {
|
||||
const raw = `{"result":[
|
||||
{"labelId":1,"name":"sales"},
|
||||
{"labelId":2,"name":"market"}
|
||||
]}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if roles := listRolesProject(data); len(roles) != 2 {
|
||||
t.Fatalf("flat shape: want 2 roles, got %d (%v)", len(roles), roles)
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMemberListProjectDeptUserListShape guards against projection-data-loss:
|
||||
// get_dept_members_by_deptId returns members under deptUserList, each wrapped in
|
||||
// userInfo. The resolver must probe deptUserList AND unwrap userInfo or
|
||||
// +list-dept-members silently returns empty despite the backend returning members.
|
||||
func TestMemberListProjectDeptUserListShape(t *testing.T) {
|
||||
const raw = `{"deptUserList":[
|
||||
{"userInfo":{"userId":"u1","name":"Alice"}},
|
||||
{"userInfo":{"userId":"u2","name":"Bob"}}
|
||||
]}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
got := memberListProject(data)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: deptUserList has 2 members, projection returned %d (%v)", len(got), got)
|
||||
}
|
||||
if got[0]["userId"] == nil || got[0]["name"] == nil {
|
||||
t.Fatalf("userInfo fields not unwrapped: %v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemberListProjectLabelUserListShape guards the role-member path:
|
||||
// get_label_members_by_labelId nests members under labelUserList, each wrapped
|
||||
// in userInfo. The resolver must probe labelUserList AND unwrap userInfo or
|
||||
// +list-role-members silently returns empty despite the role having members.
|
||||
func TestMemberListProjectLabelUserListShape(t *testing.T) {
|
||||
const raw = `{"labelUserList":[
|
||||
{"userInfo":{"userId":"u1","name":"Carol"}},
|
||||
{"userInfo":{"userId":"u2","name":"Dave"}}
|
||||
]}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
got := memberListProject(data)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: labelUserList has 2 members, projection returned %d (%v)", len(got), got)
|
||||
}
|
||||
if got[0]["userId"] == nil || got[0]["name"] == nil {
|
||||
t.Fatalf("userInfo not unwrapped: %v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemberListProjectFlatRoleShape ensures the shared projection still handles
|
||||
// a flat member shape unchanged.
|
||||
func TestMemberListProjectFlatRoleShape(t *testing.T) {
|
||||
const raw = `{"result":[{"userId":"u9","name":"Alice"}]}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if got := memberListProject(data); len(got) != 1 {
|
||||
t.Fatalf("flat role-member shape: want 1, got %d (%v)", len(got), got)
|
||||
}
|
||||
}
|
||||
@@ -495,16 +495,8 @@ var Recent = shortcut.Shortcut{
|
||||
// document list, dropping transport noise (logId) while keeping the pagination
|
||||
// cursor.
|
||||
func recentListProject(data map[string]any) map[string]any {
|
||||
// get_recent_list nests its payload under result.recentItems; earlier this
|
||||
// read data["recentItems"] at the top level only, so the whole list silently
|
||||
// projected to empty. Resolve the payload container (top-level or one level
|
||||
// under result/data) before reading recentItems / pagination fields.
|
||||
payload := data
|
||||
if inner, ok := recentListPayload(data); ok {
|
||||
payload = inner
|
||||
}
|
||||
items := []map[string]any{}
|
||||
raw, _ := payload["recentItems"].([]any)
|
||||
raw, _ := data["recentItems"].([]any)
|
||||
for _, it := range raw {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
@@ -520,31 +512,15 @@ func recentListProject(data map[string]any) map[string]any {
|
||||
})
|
||||
}
|
||||
out := map[string]any{"count": len(items), "items": items}
|
||||
if nc, ok := payload["nextCursor"]; ok && nc != nil {
|
||||
if nc, ok := data["nextCursor"]; ok && nc != nil {
|
||||
out["nextCursor"] = nc
|
||||
}
|
||||
if hm, ok := payload["hasMore"]; ok {
|
||||
if hm, ok := data["hasMore"]; ok {
|
||||
out["hasMore"] = hm
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recentListPayload returns the map that actually holds recentItems, tolerating
|
||||
// a {result|data:{recentItems:[...]}} envelope as well as a bare top-level shape.
|
||||
func recentListPayload(data map[string]any) (map[string]any, bool) {
|
||||
if _, ok := data["recentItems"]; ok {
|
||||
return data, true
|
||||
}
|
||||
for _, k := range []string{"result", "data"} {
|
||||
if inner, ok := data[k].(map[string]any); ok {
|
||||
if _, ok := inner["recentItems"]; ok {
|
||||
return inner, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
List,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRecentListProjectResultWrapper guards against projection-data-loss:
|
||||
// get_recent_list nests its payload under result.recentItems; the projection
|
||||
// must descend into result or +recent silently returns empty despite backend
|
||||
// records.
|
||||
func TestRecentListProjectResultWrapper(t *testing.T) {
|
||||
const raw = `{"result":{"hasMore":true,"nextCursor":"c2","recentItems":[
|
||||
{"name":"weekly report","nodeType":"doc","nodeId":"n1","docUrl":"https://x/1"},
|
||||
{"name":"budget sheet","nodeType":"sheet","nodeId":"n2","docUrl":"https://x/2"}
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
out := recentListProject(data)
|
||||
if got, _ := out["count"].(int); got != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.recentItems has 2 entries, projection count=%v (%v)", out["count"], out)
|
||||
}
|
||||
if out["hasMore"] != true || out["nextCursor"] != "c2" {
|
||||
t.Fatalf("pagination fields lost from result wrapper: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecentListProjectNoItems covers the no-recentItems branch (payload not
|
||||
// found under any wrapper), which must yield an empty list, not a panic.
|
||||
func TestRecentListProjectNoItems(t *testing.T) {
|
||||
const raw = `{"result":{"totalCount":0},"success":true}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if out := recentListProject(data); out["count"].(int) != 0 {
|
||||
t.Fatalf("no recentItems: want count 0, got %v", out["count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecentListProjectTopLevel covers the already-unwrapped shape.
|
||||
func TestRecentListProjectTopLevel(t *testing.T) {
|
||||
const raw = `{"recentItems":[{"name":"weekly report","nodeId":"n1"}],"hasMore":false}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if out := recentListProject(data); out["count"].(int) != 1 {
|
||||
t.Fatalf("top-level recentItems: want count 1, got %v", out["count"])
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCallListProjectItemListShape guards against projection-data-loss end to
|
||||
// end: list_by_keyword_and_time_range nests the minutes under result.itemList,
|
||||
// so the resolver must probe "itemList", and each projected row must carry the
|
||||
// real taskUuid that record-control commands consume. minutesId is deliberately
|
||||
// not treated as a taskUuid (see callListProject).
|
||||
func TestCallListProjectItemListShape(t *testing.T) {
|
||||
const raw = `{"result":{"itemList":[
|
||||
{"taskUuid":"uuid-1","title":"weekly sync"},
|
||||
{"task_uuid":"uuid-2","title":"design review"}
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
got := callListProject(data)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: itemList has 2 entries, projection returned %d", len(got))
|
||||
}
|
||||
if got[0]["taskUuid"] != "uuid-1" || got[1]["taskUuid"] != "uuid-2" {
|
||||
t.Fatalf("taskUuid not projected from taskUuid/task_uuid keys: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -113,13 +113,7 @@ func callListProject(data map[string]any) []map[string]any {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
// Only real taskUuid spellings map to taskUuid: the downstream
|
||||
// +record-pause/resume/stop commands feed this value straight into the
|
||||
// recording-control tool as the task uuid, so a minutesId (the minutes
|
||||
// document id, a different identifier) must not be substituted here or
|
||||
// record control would fail with a wrong id. The backend list already
|
||||
// returns taskUuid.
|
||||
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid"); ok {
|
||||
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid", "id"); ok {
|
||||
row["taskUuid"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "title", "name"); ok {
|
||||
@@ -150,10 +144,7 @@ func callListProject(data map[string]any) []map[string]any {
|
||||
// callListResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array or nesting under result/data/list/items/records containers.
|
||||
func callListResolveList(data map[string]any) []any {
|
||||
// list_by_keyword_and_time_range nests the minutes under result.itemList;
|
||||
// "itemList" MUST be probed or +list-all/+list-mine/+list-shared silently
|
||||
// return empty despite the backend returning minutes.
|
||||
for _, key := range []string{"result", "data", "list", "items", "itemList", "records", "dataList"} {
|
||||
for _, key := range []string{"result", "data", "list", "items", "records", "dataList"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
@@ -163,7 +154,7 @@ func callListResolveList(data map[string]any) []any {
|
||||
}
|
||||
// container may itself wrap the list one level deeper
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "itemList", "records", "dataList", "result", "data"} {
|
||||
for _, ik := range []string{"list", "items", "records", "dataList", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package oa
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss
|
||||
// class: list_user_visible_process nests the forms under result.processCodeList.
|
||||
// The resolver MUST probe that exact key — otherwise the whole list silently
|
||||
// projects to empty (exit 0, no error envelope) while the backend has data.
|
||||
func TestListFormsProjectProcessCodeListShape(t *testing.T) {
|
||||
// Faithful list_user_visible_process shape (as returned by the backend).
|
||||
const raw = `{"result":{"processCodeList":[
|
||||
{"processCode":"PROC-1","processName":"leave","dirName":"attendance"},
|
||||
{"processCode":"PROC-2","processName":"check-in fix","dirName":"attendance"},
|
||||
{"processCode":"PROC-3","processName":"overtime","dirName":"attendance"}
|
||||
],"totalCount":-1},"success":true}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
|
||||
forms := listFormsProject(data)
|
||||
if len(forms) != 3 {
|
||||
t.Fatalf("lower/upper mismatch: 3 forms in backend, projection returned %d (forms=%v)", len(forms), forms)
|
||||
}
|
||||
for _, f := range forms {
|
||||
if f["processCode"] == nil || f["name"] == nil {
|
||||
t.Fatalf("projected form missing processCode/name: %v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListFormsProjectBareArrayShape ensures a bare top-level array still works.
|
||||
func TestListFormsProjectBareArrayShape(t *testing.T) {
|
||||
const raw = `{"result":[
|
||||
{"processCode":"PROC-9","processName":"generic approval"}
|
||||
]}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if forms := listFormsProject(data); len(forms) != 1 {
|
||||
t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOAInstanceResolveListValuesShape guards the shared resolver behind
|
||||
// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval
|
||||
// instance tools nest the list under result.values; the resolver must probe
|
||||
// "values" or all four commands silently return empty despite backend records.
|
||||
func TestOAInstanceResolveListValuesShape(t *testing.T) {
|
||||
const raw = `{"result":{"hasMore":false,"values":[
|
||||
{"processInstanceId":"i-1","title":"user leave"},
|
||||
{"processInstanceId":"i-2","title":"user reimbursement"}
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if got := oaInstanceResolveList(data); len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.values has 2 entries, resolver returned %d", len(got))
|
||||
}
|
||||
// End-to-end through a real projection that uses the shared resolver.
|
||||
if instances := listSubmittedProject(data); len(instances) != 2 {
|
||||
t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances)
|
||||
}
|
||||
}
|
||||
@@ -137,11 +137,7 @@ func oaFormResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
// list_user_visible_process nests the forms under result.processCodeList;
|
||||
// probing must include that exact key (both at the top level in case the
|
||||
// envelope is already unwrapped, and one level deeper under result/data) or
|
||||
// the whole list silently projects to empty.
|
||||
for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} {
|
||||
for _, key := range []string{"result", "data", "list", "items", "processList", "forms"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
@@ -150,7 +146,7 @@ func oaFormResolveList(data map[string]any) []any {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} {
|
||||
for _, ik := range []string{"list", "items", "processList", "forms", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
@@ -289,12 +285,7 @@ func oaInstanceResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
// The approval instance tools (list_pending_approvals, get_done_tasks,
|
||||
// get_submitted_instances, get_noticed_instances) all nest the instance list
|
||||
// under result.values; "values" MUST be in the probe set or every one of
|
||||
// +list-pending / +list-executed / +list-submitted / +list-cc silently
|
||||
// projects to empty despite the backend returning records.
|
||||
for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} {
|
||||
for _, key := range []string{"result", "data", "list", "items", "instances", "tasks"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
@@ -303,7 +294,7 @@ func oaInstanceResolveList(data map[string]any) []any {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} {
|
||||
for _, ik := range []string{"list", "items", "instances", "tasks", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
|
||||
@@ -131,11 +131,7 @@ func reportEntryListResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
// get_received_report_list / get_send_report_list nest the list under
|
||||
// result.report_list (snake_case); "report_list" MUST be probed — the
|
||||
// camelCase "reportList" alone leaves +inbox-list / +outbox-list silently
|
||||
// empty despite the backend returning reports.
|
||||
for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} {
|
||||
for _, key := range []string{"result", "data", "list", "items", "reportList", "records"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
@@ -144,7 +140,7 @@ func reportEntryListResolveList(data map[string]any) []any {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} {
|
||||
for _, ik := range []string{"list", "items", "reportList", "records", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestReportEntryListResolveListSnakeShape guards against projection-data-loss:
|
||||
// get_received_report_list / get_send_report_list nest the list under
|
||||
// result.report_list (snake_case); the resolver must probe "report_list" or
|
||||
// +inbox-list / +outbox-list silently return empty despite the backend having
|
||||
// reports.
|
||||
func TestReportEntryListResolveListSnakeShape(t *testing.T) {
|
||||
const raw = `{"result":{"report_list":[
|
||||
{"reportId":"r1","templateName":"daily report"},
|
||||
{"reportId":"r2","templateName":"weekly report"}
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if got := reportEntryListResolveList(data); len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.report_list has 2 entries, resolver returned %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,12 @@ var CreatedTodos = shortcut.Shortcut{
|
||||
`dws todo +created-todos`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — list my todos as creator via the shared pager. This keeps
|
||||
// pageSize at todoPageSize(=20): the backend silently returns an empty
|
||||
// page for pageSize>20, so the previous hard-coded pageSize=50 made this
|
||||
// command always return empty even when the creator had todos. The pager
|
||||
// also merges every page instead of only the first.
|
||||
cards, err := shortcutListAllTodoCards(rt, map[string]any{
|
||||
// Step 1 — list my todos as creator. pageNum/pageSize are strings and
|
||||
// roleTypes=["creator"] mirrors helpers.buildListTodoTaskArgs, where
|
||||
// "creator" is an accepted role-type enum value.
|
||||
data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", map[string]any{
|
||||
"pageNum": "1",
|
||||
"pageSize": "50",
|
||||
"roleTypes": []string{"creator"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -69,6 +69,7 @@ var CreatedTodos = shortcut.Shortcut{
|
||||
|
||||
// Step 2 — project each card to {subject, taskId, dueTime}, parsing
|
||||
// fields defensively (helpers reused from todo_done.go / overdue.go).
|
||||
cards := shortcutTodoCards(data)
|
||||
created := make([]map[string]any, 0, len(cards))
|
||||
for _, m := range cards {
|
||||
subject, _ := m["subject"].(string)
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var errStubTool = errors.New("stub tool error")
|
||||
|
||||
// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with
|
||||
// an email field and bare string arrays, each at the top level or one level
|
||||
// under a result/data wrapper. The nested bare-string case regressed silently
|
||||
// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly
|
||||
// demand an explicit --email.
|
||||
func TestSearchMailPickMailbox(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, raw, want string
|
||||
}{
|
||||
{"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"},
|
||||
{"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"},
|
||||
{"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"},
|
||||
{"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"},
|
||||
{"empty", `{"result":{"emailAccounts":[]}}`, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.raw), &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := searchMailPickMailbox(data); got != tc.want {
|
||||
t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces
|
||||
// container is probed — otherwise +resolve-space silently finds no candidates.
|
||||
func TestResolveSpaceItemsWikiSpaces(t *testing.T) {
|
||||
const raw = `{"result":{"wikiSpaces":[
|
||||
{"spaceId":"s1","name":"R&D wiki"},
|
||||
{"spaceId":"s2","name":"product wiki"}
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resolveSpaceItems(data); len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.wikiSpaces has 2 entries, resolver returned %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// --- fake MCP caller for end-to-end shortcut projection tests ---
|
||||
|
||||
type stubMailboxCaller struct {
|
||||
byTool map[string]string
|
||||
errTool string // tool name that should return a transport error
|
||||
}
|
||||
|
||||
func (f *stubMailboxCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
if tool == f.errTool {
|
||||
return nil, errStubTool
|
||||
}
|
||||
text := f.byTool[tool]
|
||||
if text == "" {
|
||||
text = `{"result":{}}`
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
func (f *stubMailboxCaller) Format() string { return "json" }
|
||||
func (f *stubMailboxCaller) DryRun() bool { return false }
|
||||
func (f *stubMailboxCaller) Fields() string { return "" }
|
||||
func (f *stubMailboxCaller) JQ() string { return "" }
|
||||
|
||||
func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string {
|
||||
t.Helper()
|
||||
helpers.InitDeps(fake)
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(argv)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute %v: %v", argv, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDeps(fake)
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(argv)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
// TestSmartSearchMailNoMailbox covers the empty-mailbox path: when
|
||||
// list_user_mailboxes yields no address, +search-mail must surface a clear
|
||||
// "provide --email" validation error rather than silently proceeding.
|
||||
func TestSmartSearchMailNoMailbox(t *testing.T) {
|
||||
fake := &stubMailboxCaller{byTool: map[string]string{
|
||||
"list_user_mailboxes": `{"result":{"emailAccounts":[]}}`,
|
||||
}}
|
||||
err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json")
|
||||
if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") {
|
||||
t.Fatalf("want no-mailbox validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSmartSearchMailBackendError covers the transport-error branch of the
|
||||
// mailbox resolver.
|
||||
func TestSmartSearchMailBackendError(t *testing.T) {
|
||||
fake := &stubMailboxCaller{byTool: map[string]string{}, errTool: "list_user_mailboxes"}
|
||||
if err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json"); err == nil {
|
||||
t.Fatal("want backend error propagated, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatedTodosBackendError covers the error branch of +created-todos when the
|
||||
// paged todo fetch fails.
|
||||
func TestCreatedTodosBackendError(t *testing.T) {
|
||||
fake := &stubMailboxCaller{byTool: map[string]string{}, errTool: "get_user_todos_in_current_org"}
|
||||
if err := runShortcutErr(t, fake, "todo", "+created-todos", "--format", "json"); err == nil {
|
||||
t.Fatal("want backend error propagated, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatedTodosProjectsCards runs +created-todos end to end through a fake MCP
|
||||
// caller. get_user_todos_in_current_org nests cards under result.todoCards; the
|
||||
// shared pager (pageSize=20, not the old 50 that silently returned empty) must
|
||||
// surface them into the projected "created" list.
|
||||
func TestCreatedTodosProjectsCards(t *testing.T) {
|
||||
fake := &stubMailboxCaller{byTool: map[string]string{
|
||||
"get_user_todos_in_current_org": `{"result":{"todoCards":[
|
||||
{"subject":"todoA","taskId":"111","dueTime":1784686500000},
|
||||
{"subject":"todoB","taskId":"222"}
|
||||
]}}`,
|
||||
}}
|
||||
out := runShortcut(t, fake, "todo", "+created-todos", "--format", "json")
|
||||
var d map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &d); err != nil {
|
||||
t.Fatalf("output not json: %q", out)
|
||||
}
|
||||
created, _ := d["created"].([]any)
|
||||
if len(created) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: 2 todos in backend, projection created=%d (%s)", len(created), out)
|
||||
}
|
||||
if !strings.Contains(out, "111") || !strings.Contains(out, "222") {
|
||||
t.Fatalf("projected rows missing taskId: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSmartSearchMailAutoResolvesMailbox runs +search-mail without --email so the
|
||||
// shortcut must auto-resolve the mailbox from list_user_mailboxes
|
||||
// (result.emailAccounts) before searching. Before the fix this returned
|
||||
// the no-mailbox validation error because emailAccounts was not probed.
|
||||
func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) {
|
||||
fake := &stubMailboxCaller{byTool: map[string]string{
|
||||
"list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`,
|
||||
"search_emails": `{"result":{"messages":[{"subject":"weekly report","from":"a@x.com"}]}}`,
|
||||
}}
|
||||
out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:weekly report", "--format", "json")
|
||||
if strings.Contains(out, "未找到可用邮箱") || out == "" {
|
||||
t.Fatalf("mailbox auto-resolve failed: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "weekly report") {
|
||||
t.Fatalf("search result not projected: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func resolveSpaceItems(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
for _, key := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "records"} {
|
||||
for _, key := range []string{"result", "data", "list", "items", "spaces", "records"} {
|
||||
raw, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
@@ -112,49 +112,23 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if addr := searchMailPickMailbox(data); addr != "" {
|
||||
return addr, nil
|
||||
}
|
||||
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址")
|
||||
}
|
||||
|
||||
// searchMailPickMailbox resolves the first usable mailbox address from a
|
||||
// list_user_mailboxes response. It tolerates every observed shape: objects with
|
||||
// an email field (result.emailAccounts[].email) and bare string arrays
|
||||
// (result.emailAccounts[]), each at the top level or one level under a
|
||||
// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string
|
||||
// case is scanned separately — otherwise all three smart mail shortcuts wrongly
|
||||
// report that no mailbox exists.
|
||||
func searchMailPickMailbox(data map[string]any) string {
|
||||
containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"}
|
||||
for _, m := range searchMailUnwrapList(data, containers...) {
|
||||
boxes := searchMailUnwrapList(data, "mailboxes", "list", "items", "data", "result", "records")
|
||||
for _, m := range boxes {
|
||||
if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" {
|
||||
return addr
|
||||
return addr, nil
|
||||
}
|
||||
}
|
||||
scanStrings := func(m map[string]any) string {
|
||||
for _, key := range containers {
|
||||
if arr, ok := m[key].([]any); ok {
|
||||
for _, it := range arr {
|
||||
if s, ok := it.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
// Some gateways return the addresses as a bare string list.
|
||||
for _, key := range []string{"mailboxes", "list", "items", "data", "result"} {
|
||||
if arr, ok := data[key].([]any); ok {
|
||||
for _, it := range arr {
|
||||
if s, ok := it.(string); ok && s != "" {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if s := scanStrings(data); s != "" {
|
||||
return s
|
||||
}
|
||||
for _, wrap := range []string{"result", "data"} {
|
||||
if inner, ok := data[wrap].(map[string]any); ok {
|
||||
if s := scanStrings(inner); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址")
|
||||
}
|
||||
|
||||
// searchMailMessages extracts the message list from a search_emails response.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSpaceListProjectWikiSpacesShape guards against projection-data-loss:
|
||||
// list_wikiSpaces / search_wikiSpaces nest the list under result.wikiSpaces;
|
||||
// the resolver must probe "wikiSpaces" or +space-list / +space-search silently
|
||||
// return empty despite the backend returning spaces.
|
||||
func TestSpaceListProjectWikiSpacesShape(t *testing.T) {
|
||||
const raw = `{"result":{"hasMore":false,"wikiSpaces":[
|
||||
{"workspaceId":"w1","name":"R&D wiki"},
|
||||
{"workspaceId":"w2","name":"product wiki"}
|
||||
]},"success":true}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if spaces := spaceListProject(data); len(spaces) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.wikiSpaces has 2 entries, projection returned %d (%v)", len(spaces), spaces)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpaceListProjectTopLevelWikiSpaces covers the already-unwrapped shape.
|
||||
func TestSpaceListProjectTopLevelWikiSpaces(t *testing.T) {
|
||||
const raw = `{"wikiSpaces":[{"workspaceId":"w1","name":"R&D wiki"}]}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if spaces := spaceListProject(data); len(spaces) != 1 {
|
||||
t.Fatalf("top-level wikiSpaces: want 1, got %d (%v)", len(spaces), spaces)
|
||||
}
|
||||
}
|
||||
@@ -94,15 +94,12 @@ func spaceListProject(data map[string]any) []map[string]any {
|
||||
// wikiSpaceRawList locates the space array across candidate container keys,
|
||||
// tolerating a nested {result|data:{list|items|spaces}} wrapper.
|
||||
func wikiSpaceRawList(data map[string]any) []any {
|
||||
// list_wikiSpaces / search_wikiSpaces nest the space list under
|
||||
// result.wikiSpaces (or a top-level wikiSpaces once unwrapped); "wikiSpaces"
|
||||
// MUST be probed or +space-list / +space-search silently return empty.
|
||||
for _, k := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "workspaces"} {
|
||||
for _, k := range []string{"result", "data", "list", "items", "spaces", "workspaces"} {
|
||||
if arr, ok := data[k].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := data[k].(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "wikiSpaces", "spaces", "workspaces", "result", "data"} {
|
||||
for _, ik := range []string{"list", "items", "spaces", "workspaces", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ func StaticServers() []ServerInfo {
|
||||
{ID: "doc", Name: "文档", Endpoint: "https://mcp-gw.dingtalk.com/server/91e17caf44f6ca1ed9c6ce614221a518ac93300ece63ca8d7e9b133f912e0607", Prefixes: []string{"doc"}},
|
||||
{ID: "doc-comment", Name: "文档评论", Endpoint: "https://mcp-gw.dingtalk.com/server/doc-comment", Prefixes: []string{"doc-comment"}},
|
||||
{ID: "drive", Name: "钉盘", Endpoint: "https://mcp-gw.dingtalk.com/server/536f3b329ee774322b14361c666d6e9471e5bbb281b91ded8ca033b3ce7189af", Prefixes: []string{"drive"}},
|
||||
{ID: "hrbrain", Name: "组织大脑", Endpoint: "https://mcp-gw.dingtalk.com/server/377efb1093f09206cea6ccd030880d274668fb2e7a4b1bb7b96996a4fe7bb619", Prefixes: []string{"hrbrain"}},
|
||||
{ID: "hrmregister", Name: "智能人事", Endpoint: "https://mcp-gw.dingtalk.com/server/2494acfc37317871865c9c715664792200a52a89aeab58fee61cfc7262463c39", Prefixes: []string{"hrmregister"}},
|
||||
{ID: "im", Name: "im", Endpoint: "https://mcp-gw.dingtalk.com/server/450eede6b54d83e030140e66ec77c98a2e89a0869ef4db481f8217a98a42f821", Prefixes: []string{"im"}},
|
||||
{ID: "live", Name: "直播", Endpoint: "https://mcp-gw.dingtalk.com/server/57fb66889659c56df8f0a1838026b9004d8f4257fbef54fd04a028426253a033", Prefixes: []string{"live"}},
|
||||
|
||||
@@ -16,7 +16,6 @@ func CmdToProduct() map[string]string {
|
||||
"ding": "ding",
|
||||
"doc": "doc",
|
||||
"drive": "drive",
|
||||
"hrbrain": "hrbrain",
|
||||
"live": "live",
|
||||
"mail": "mail",
|
||||
"minutes": "minutes",
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 Alibaba Group
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: $0 <changed|list> BASE_REF HEAD_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
[ "${1:-}" = changed ] || [ "${1:-}" = list ] || usage
|
||||
[ "$#" -eq 3 ] || usage
|
||||
|
||||
MODE="$1"
|
||||
BASE_REF="$2"
|
||||
HEAD_REF="$3"
|
||||
|
||||
cd "$ROOT"
|
||||
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
|
||||
printf 'changed test package base is not a commit: %s\n' "$BASE_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
git rev-parse --verify --quiet "${HEAD_REF}^{commit}" >/dev/null || {
|
||||
printf 'changed test package head is not a commit: %s\n' "$HEAD_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
resolved_head="$(git rev-parse "${HEAD_REF}^{commit}")"
|
||||
current_head="$(git rev-parse HEAD)"
|
||||
[ "$current_head" = "$resolved_head" ] || {
|
||||
printf 'changed test package head does not match the checked-out revision: expected %s, got %s\n' \
|
||||
"$resolved_head" "$current_head" >&2
|
||||
exit 2
|
||||
}
|
||||
git diff --quiet HEAD -- && git diff --cached --quiet HEAD -- || {
|
||||
printf '%s\n' 'changed test package planning requires a clean tracked worktree' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-changed-packages.XXXXXX")"
|
||||
trap 'rm -rf "$workdir"' EXIT HUP INT TERM
|
||||
files="$workdir/files"
|
||||
changed_packages="$workdir/changed-packages"
|
||||
impacted_packages="$workdir/impacted-packages"
|
||||
all_packages="$workdir/all-packages"
|
||||
package_dependencies="$workdir/package-dependencies"
|
||||
embed_owners="$workdir/embed-owners"
|
||||
: > "$changed_packages"
|
||||
: > "$impacted_packages"
|
||||
|
||||
git diff --no-ext-diff --find-renames --name-only \
|
||||
--diff-filter=ACMRD "$BASE_REF" "$HEAD_REF" > "$files"
|
||||
|
||||
if ! go list ./... > "$all_packages"; then
|
||||
printf '%s\n' 'failed to resolve the module package graph' >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! go list -f \
|
||||
'{{- $import := .ImportPath -}}{{- $dir := .Dir -}}{{- range .EmbedFiles }}{{$import}}|{{$dir}}|{{.}}{{"\n"}}{{- end -}}{{- range .TestEmbedFiles }}{{$import}}|{{$dir}}|{{.}}{{"\n"}}{{- end -}}{{- range .XTestEmbedFiles }}{{$import}}|{{$dir}}|{{.}}{{"\n"}}{{- end -}}' \
|
||||
./... > "$embed_owners"; then
|
||||
printf '%s\n' 'failed to resolve embedded file ownership' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -n "$file" ] || continue
|
||||
case "$file" in
|
||||
CHANGELOG.md|README.md|README_zh.md|CONTRIBUTING.md|SECURITY.md|\
|
||||
CODE_OF_CONDUCT.md|LICENSE|NOTICE|docs/*|\
|
||||
.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
directory="$(dirname "$file")"
|
||||
if [ "$directory" = . ]; then
|
||||
pattern=.
|
||||
else
|
||||
pattern="./$directory"
|
||||
fi
|
||||
if [ -d "$directory" ]; then
|
||||
has_go_files=false
|
||||
for go_file in "$directory"/*.go; do
|
||||
if [ -f "$go_file" ]; then
|
||||
has_go_files=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$has_go_files" = true ]; then
|
||||
if ! package="$(go list -f '{{.ImportPath}}' "$pattern")"; then
|
||||
printf 'failed to resolve changed Go package: %s\n' "$directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$package" >> "$changed_packages"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
embedded_owner_found=false
|
||||
while IFS='|' read -r package package_dir embedded_file; do
|
||||
[ -n "$package" ] || continue
|
||||
case "$package_dir" in
|
||||
"$ROOT") embedded_path="$embedded_file" ;;
|
||||
"$ROOT"/*)
|
||||
embedded_path="${package_dir#"$ROOT"/}/$embedded_file"
|
||||
;;
|
||||
*) continue ;;
|
||||
esac
|
||||
if [ "$embedded_path" = "$file" ]; then
|
||||
printf '%s\n' "$package" >> "$changed_packages"
|
||||
embedded_owner_found=true
|
||||
fi
|
||||
done < "$embed_owners"
|
||||
[ "$embedded_owner_found" = true ] || continue
|
||||
done < "$files"
|
||||
|
||||
LC_ALL=C sort -u "$changed_packages" -o "$changed_packages"
|
||||
if [ "$MODE" = changed ] || [ ! -s "$changed_packages" ]; then
|
||||
cat "$changed_packages"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r package; do
|
||||
if ! go list -deps -test "$package" > "$package_dependencies"; then
|
||||
printf 'failed to resolve dependencies for Go package: %s\n' "$package" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Fxf "$changed_packages" "$package_dependencies" >/dev/null; then
|
||||
printf '%s\n' "$package"
|
||||
fi
|
||||
done < "$all_packages" > "$impacted_packages"
|
||||
|
||||
cat "$changed_packages" >> "$impacted_packages"
|
||||
LC_ALL=C sort -u "$impacted_packages"
|
||||
@@ -6,25 +6,16 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from shortcut_real_result import (
|
||||
classify_failure,
|
||||
summarize_failure_categories,
|
||||
classify_real_status,
|
||||
summarize_results,
|
||||
projection_audit,
|
||||
)
|
||||
from shortcut_real_result import classify_failure, summarize_failure_categories, classify_real_status, summarize_results
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT_PATH = Path(
|
||||
os.environ.get("SHORTCUT_REAL_RESULTS_PATH", ROOT / "docs" / "shortcut-real-write-results.json")
|
||||
)
|
||||
OUT_PATH = ROOT / "docs" / "shortcut-real-write-results.json"
|
||||
|
||||
|
||||
def load_report() -> dict:
|
||||
@@ -48,12 +39,6 @@ def main() -> int:
|
||||
p.add_argument("--risk", default="write")
|
||||
p.add_argument("--method", default="real-backend-manual-write; no --mock; no --dry-run")
|
||||
p.add_argument("--timeout", type=int, default=35)
|
||||
p.add_argument(
|
||||
"--backend-raw",
|
||||
help="Path to a JSON file holding the lower-layer MCP tool response for "
|
||||
"this shortcut (captured separately). Used only in memory to compare the "
|
||||
"upper projection against the lower layer; never persisted.",
|
||||
)
|
||||
p.add_argument("argv", nargs=argparse.REMAINDER)
|
||||
ns = p.parse_args()
|
||||
argv = ns.argv
|
||||
@@ -62,12 +47,6 @@ def main() -> int:
|
||||
if not argv:
|
||||
raise SystemExit("missing command argv")
|
||||
|
||||
# Lower layer stays in memory only — we persist derived counts, never the raw
|
||||
# PII-bearing backend payload.
|
||||
backend_raw = None
|
||||
if ns.backend_raw:
|
||||
backend_raw = json.loads(Path(ns.backend_raw).read_text(encoding="utf-8"))
|
||||
|
||||
started = time.time()
|
||||
status = "real-error"
|
||||
stdout = ""
|
||||
@@ -78,7 +57,7 @@ def main() -> int:
|
||||
stdout = proc.stdout.strip()
|
||||
stderr = proc.stderr.strip()
|
||||
exit_code = proc.returncode
|
||||
status = classify_real_status(exit_code, stdout, backend_raw=backend_raw)
|
||||
status = classify_real_status(exit_code, stdout)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
status = "timeout"
|
||||
if isinstance(e.stdout, str):
|
||||
@@ -104,17 +83,7 @@ def main() -> int:
|
||||
"duration_ms": duration_ms,
|
||||
"recorded_at": dt.datetime.now().isoformat(),
|
||||
}
|
||||
# Compare upper projection vs lower layer in memory; persist only the derived
|
||||
# verdict (kind + counts), never the raw backend payload.
|
||||
if backend_raw is not None:
|
||||
audit = projection_audit({**result, "backend": backend_raw})
|
||||
if audit is not None:
|
||||
result["projection_audit"] = audit
|
||||
|
||||
# classify_failure inspects the lower layer to flag projection-data-loss, so
|
||||
# hand it a copy carrying the in-memory backend; the persisted `result` keeps
|
||||
# only the verdict.
|
||||
category, fixability, note = classify_failure({**result, "backend": backend_raw})
|
||||
category, fixability, note = classify_failure(result)
|
||||
if category != "passed":
|
||||
result["failure_category"] = category
|
||||
result["fixability"] = fixability
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
FORMULA_SOURCE="${DWS_FORMULA_SOURCE:-$ROOT/dist/homebrew/dingtalk-workspace-cli.rb}"
|
||||
TAP_REPO_URL="${DWS_TAP_REPO_URL:-}"
|
||||
TAP_BRANCH="${DWS_TAP_BRANCH:-main}"
|
||||
@@ -14,7 +14,6 @@ PR_TITLE="${DWS_TAP_PR_TITLE:-chore: update Homebrew formula}"
|
||||
COMMIT_MESSAGE="${DWS_TAP_COMMIT_MESSAGE:-chore: update dingtalk-workspace-cli formula}"
|
||||
GIT_NAME="${DWS_GIT_NAME:-DWS Release Bot}"
|
||||
GIT_EMAIL="${DWS_GIT_EMAIL:-dws-release-bot@example.com}"
|
||||
PUBLISH_OUTPUT="${DWS_PUBLISH_OUTPUT:-${GITHUB_OUTPUT:-}}"
|
||||
|
||||
say() {
|
||||
printf '%s\n' "$*"
|
||||
@@ -39,15 +38,6 @@ need_env() {
|
||||
[ -n "$value" ] || err "missing required environment variable: $name"
|
||||
}
|
||||
|
||||
record_direct_result() {
|
||||
changed="$1"
|
||||
commit="$2"
|
||||
[ -z "$PUBLISH_OUTPUT" ] || {
|
||||
printf 'formula_changed=%s\npublished_commit=%s\n' "$changed" "$commit" \
|
||||
>> "$PUBLISH_OUTPUT"
|
||||
}
|
||||
}
|
||||
|
||||
checkout_tap_branch() {
|
||||
repo_url="$1"
|
||||
branch="$2"
|
||||
@@ -97,7 +87,6 @@ if [ -n "$TAP_SSH_KEY" ]; then
|
||||
fi
|
||||
if [ -n "$TAP_GITHUB_TOKEN" ]; then
|
||||
ASKPASS="$TMP_ROOT/git-askpass.sh"
|
||||
# shellcheck disable=SC2016 # Preserve variables for the generated helper.
|
||||
printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'case "$1" in' \
|
||||
@@ -116,70 +105,7 @@ if [ -n "$PR_REPOSITORY" ] || [ -n "$PR_BRANCH" ]; then
|
||||
need_cmd gh
|
||||
fi
|
||||
|
||||
if [ -z "$PR_REPOSITORY" ]; then
|
||||
case "$TAP_FORMULA_PATH" in
|
||||
Formula/dingtalk-workspace-cli.rb|Formula/dingtalk-workspace-cli-beta.rb) ;;
|
||||
*) err "direct Homebrew publication is restricted to the stable or beta DWS Formula" ;;
|
||||
esac
|
||||
attempt=1
|
||||
max_attempts=3
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
TAP_DIR="$TMP_ROOT/tap-direct-$attempt"
|
||||
checkout_tap_branch "$TAP_REPO_URL" "$TAP_BRANCH" "$TAP_DIR"
|
||||
|
||||
DEST_PATH="$TAP_DIR/$TAP_FORMULA_PATH"
|
||||
mkdir -p "$(dirname "$DEST_PATH")"
|
||||
cp "$FORMULA_SOURCE" "$DEST_PATH"
|
||||
|
||||
if (
|
||||
cd "$TAP_DIR" || exit $?
|
||||
formula_status="$(git status --porcelain --untracked-files=all)" || exit $?
|
||||
if [ -z "$formula_status" ]; then
|
||||
formula_commit="$(git log -1 --format=%H -- "$TAP_FORMULA_PATH")" || exit $?
|
||||
[ -n "$formula_commit" ] ||
|
||||
err "could not resolve the existing commit for $TAP_FORMULA_PATH"
|
||||
record_direct_result false "$formula_commit" || exit $?
|
||||
exit 20
|
||||
fi
|
||||
case "$formula_status" in
|
||||
" M $TAP_FORMULA_PATH"|"?? $TAP_FORMULA_PATH") ;;
|
||||
*) err "direct Homebrew publication may change only $TAP_FORMULA_PATH" ;;
|
||||
esac
|
||||
git config user.name "$GIT_NAME" || exit $?
|
||||
git config user.email "$GIT_EMAIL" || exit $?
|
||||
git add "$TAP_FORMULA_PATH" || exit $?
|
||||
staged_paths="$(git diff --cached --name-only)" || exit $?
|
||||
[ "$staged_paths" = "$TAP_FORMULA_PATH" ] ||
|
||||
err "staged Homebrew publication contains files other than $TAP_FORMULA_PATH"
|
||||
git commit -m "$COMMIT_MESSAGE" >/dev/null || exit $?
|
||||
committed_paths="$(git diff-tree --no-commit-id --name-only -r HEAD)" || exit $?
|
||||
[ "$committed_paths" = "$TAP_FORMULA_PATH" ] ||
|
||||
err "Homebrew publication commit contains files other than $TAP_FORMULA_PATH"
|
||||
# Never force the tap's default branch. A concurrent main update gets a
|
||||
# fresh clone and a newly based Formula-only commit on the next attempt.
|
||||
git push origin "HEAD:$TAP_BRANCH" >/dev/null || exit $?
|
||||
published_commit="$(git rev-parse HEAD)" || exit $?
|
||||
record_direct_result true "$published_commit" || exit $?
|
||||
); then
|
||||
say "Published Homebrew formula to $TAP_REPO_URL ($TAP_BRANCH)"
|
||||
exit 0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 20 ]; then
|
||||
say "No formula changes to publish."
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
err "could not publish Homebrew formula after $max_attempts non-forced attempts"
|
||||
fi
|
||||
say "Homebrew target branch moved or rejected the update; retrying from a fresh clone."
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
fi
|
||||
|
||||
TAP_DIR="$TMP_ROOT/tap-pr"
|
||||
TAP_DIR="$TMP_ROOT/tap"
|
||||
checkout_tap_branch "$TAP_REPO_URL" "$TAP_BRANCH" "$TAP_DIR"
|
||||
|
||||
DEST_PATH="$TAP_DIR/$TAP_FORMULA_PATH"
|
||||
@@ -197,24 +123,34 @@ cp "$FORMULA_SOURCE" "$DEST_PATH"
|
||||
git config user.email "$GIT_EMAIL"
|
||||
git add "$TAP_FORMULA_PATH"
|
||||
git commit -m "$COMMIT_MESSAGE" >/dev/null
|
||||
git push --force-with-lease origin "HEAD:$PR_BRANCH" >/dev/null
|
||||
pr_url="$(
|
||||
GH_TOKEN="$TAP_GITHUB_TOKEN" gh pr list \
|
||||
--repo "$PR_REPOSITORY" \
|
||||
--head "$PR_BRANCH" \
|
||||
--state open \
|
||||
--json url \
|
||||
--jq '.[0].url'
|
||||
)"
|
||||
if [ -z "$pr_url" ]; then
|
||||
|
||||
if [ -n "$PR_REPOSITORY" ]; then
|
||||
git push --force-with-lease origin "HEAD:$PR_BRANCH" >/dev/null
|
||||
pr_url="$(
|
||||
GH_TOKEN="$TAP_GITHUB_TOKEN" gh pr create \
|
||||
GH_TOKEN="$TAP_GITHUB_TOKEN" gh pr list \
|
||||
--repo "$PR_REPOSITORY" \
|
||||
--base "$TAP_BRANCH" \
|
||||
--head "$PR_BRANCH" \
|
||||
--title "$PR_TITLE" \
|
||||
--body "Automated Homebrew Formula update. Merge after required checks pass."
|
||||
--state open \
|
||||
--json url \
|
||||
--jq '.[0].url'
|
||||
)"
|
||||
if [ -z "$pr_url" ]; then
|
||||
pr_url="$(
|
||||
GH_TOKEN="$TAP_GITHUB_TOKEN" gh pr create \
|
||||
--repo "$PR_REPOSITORY" \
|
||||
--base "$TAP_BRANCH" \
|
||||
--head "$PR_BRANCH" \
|
||||
--title "$PR_TITLE" \
|
||||
--body "Automated Homebrew Formula update. Merge after required checks pass."
|
||||
)"
|
||||
fi
|
||||
say "Opened Homebrew formula PR: $pr_url"
|
||||
exit 0
|
||||
fi
|
||||
say "Opened Homebrew formula PR: $pr_url"
|
||||
|
||||
git push origin "HEAD:$TAP_BRANCH" >/dev/null
|
||||
)
|
||||
|
||||
if [ -z "$PR_REPOSITORY" ]; then
|
||||
say "Published Homebrew formula to $TAP_REPO_URL ($TAP_BRANCH)"
|
||||
fi
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
|
||||
# shellcheck disable=SC1091 # Resolved relative to this script at runtime.
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
ROOT="$(CDPATH='' cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
VERSION="${1:-}"
|
||||
REMOTE=""
|
||||
@@ -248,7 +247,7 @@ printf ' commit: %s\n' "$commit"
|
||||
printf ' failed run: https://github.com/%s/actions/runs/%s/attempts/%s\n' \
|
||||
"$EXPECTED_REPOSITORY" "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT"
|
||||
[ -t 0 ] || { printf '%s\n' 'interactive recovery confirmation is required' >&2; exit 1; }
|
||||
printf 'Type %s to request machine-verified recovery: ' "$VERSION"
|
||||
printf 'Type %s to request protected recovery: ' "$VERSION"
|
||||
IFS= read -r confirmation
|
||||
[ "$confirmation" = "$VERSION" ] || { printf '%s\n' 'release recovery cancelled' >&2; exit 1; }
|
||||
|
||||
@@ -285,8 +284,8 @@ while :; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
printf 'Machine-verified recovery run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$run_id"
|
||||
printf '%s\n' 'The workflow will verify the exact tag, failed run, requester, and sealed metadata before rebuilding.'
|
||||
printf 'Protected recovery run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$run_id"
|
||||
printf '%s\n' 'Approve the release-recovery environment when prompted; this command will follow the run.'
|
||||
if ! gh run watch "$run_id" --repo "$EXPECTED_REPOSITORY" --exit-status; then
|
||||
gh run view "$run_id" --repo "$EXPECTED_REPOSITORY" --log-failed >&2 || true
|
||||
exit 1
|
||||
|
||||
@@ -80,120 +80,13 @@ def payload_indicates_error(stdout: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上层 / 下层数据一致性(投影保真)
|
||||
#
|
||||
# 警示案例:contact +list-roles、oa +list-forms 在 exit 0、无 error 信封的情况下
|
||||
# 依然静默返空——底层 MCP 明明有 57 个角色 / 93 张表单,投影层因为容器 key 对不上
|
||||
# (result[].labels[] 未下钻 / 缺 processCodeList)把数据全吃掉了。只看上层(便捷层
|
||||
# 投影输出)永远发现不了这类问题;**shortcut 务必对比上层投影与下层原始后端数据**。
|
||||
#
|
||||
# 判定规则因此新增一条铁律:一个只读/列表类 shortcut,若上层投影为空,必须能拿下层
|
||||
# 原始响应来佐证「本就没有数据」。拿不到下层、或下层明明有数据,都不能算 real-ok。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 便捷层投影输出里承载业务列表的常见容器字段名。
|
||||
_PROJECTION_LIST_KEYS = (
|
||||
"roles", "forms", "users", "user", "items", "list", "records", "files",
|
||||
"docs", "nodes", "messages", "conversations", "groups", "events",
|
||||
"attendees", "rooms", "threads", "folders", "tags", "templates", "contacts",
|
||||
"spaces", "tasks", "todos", "created", "members", "depts", "departments",
|
||||
"sheets", "bases", "tables", "views", "apps", "minutes", "reports",
|
||||
"comments", "attachments", "instances", "robots", "bots", "results",
|
||||
"workflows", "permissions", "versions", "calendars", "cards",
|
||||
)
|
||||
|
||||
|
||||
def count_projection_items(stdout: str) -> int | None:
|
||||
"""从便捷层投影输出里数出条目数(上层)。
|
||||
|
||||
返回 None 表示这段输出不像一个「列表型」投影(例如详情命令、纯文本、写操作
|
||||
回执),此时不参与空投影判定。识别到列表型投影时返回其条目数(可能为 0)。
|
||||
"""
|
||||
best: int | None = None
|
||||
for value in _json_values(stdout):
|
||||
if isinstance(value, list):
|
||||
best = max(best or 0, len(value))
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
count_field = value.get("count")
|
||||
if isinstance(count_field, bool):
|
||||
count_field = None
|
||||
if isinstance(count_field, int):
|
||||
best = max(best or 0, count_field)
|
||||
for key in _PROJECTION_LIST_KEYS:
|
||||
member = value.get(key)
|
||||
if isinstance(member, list):
|
||||
best = max(best or 0, len(member))
|
||||
return best
|
||||
|
||||
|
||||
def backend_record_count(raw: Any, depth: int = 0) -> int:
|
||||
"""递归估算下层原始后端响应里的业务条目数(下层真值)。
|
||||
|
||||
取任意深度下「最大的一个对象数组」的长度作为「后端是否有数据」的代理指标——
|
||||
足以判定投影是否把非空的底层数据吃成了空。
|
||||
"""
|
||||
if depth > 6 or raw is None:
|
||||
return 0
|
||||
best = 0
|
||||
if isinstance(raw, list):
|
||||
obj_items = sum(1 for item in raw if isinstance(item, dict))
|
||||
best = max(best, obj_items)
|
||||
for item in raw:
|
||||
best = max(best, backend_record_count(item, depth + 1))
|
||||
elif isinstance(raw, dict):
|
||||
for value in raw.values():
|
||||
best = max(best, backend_record_count(value, depth + 1))
|
||||
return best
|
||||
|
||||
|
||||
def _backend_payload(result: dict[str, Any]) -> Any:
|
||||
"""取出随 result 一起记录的下层原始后端响应(若采集了的话)。
|
||||
|
||||
约定字段:backend_raw / raw_backend / backend / lower_layer / backend_stdout。
|
||||
backend_stdout 是字符串时按 JSON 解析。"""
|
||||
for key in ("backend_raw", "raw_backend", "backend", "lower_layer"):
|
||||
if key in result and result[key] not in (None, "", {}, []):
|
||||
return result[key]
|
||||
text = result.get("backend_stdout")
|
||||
if isinstance(text, str) and text.strip():
|
||||
values = _json_values(text)
|
||||
if values:
|
||||
return values[0] if len(values) == 1 else values
|
||||
return None
|
||||
|
||||
|
||||
def compare_layers(shortcut_stdout: str, backend_raw: Any) -> tuple[bool, int | None, int]:
|
||||
"""对比上层投影与下层原始后端数据。
|
||||
|
||||
返回 (是否投影吃了数据, 上层条目数, 下层条目数)。当上层是列表型投影且为空、
|
||||
而下层明显有对象数组时,判定为投影数据丢失(True)。"""
|
||||
upper = count_projection_items(shortcut_stdout)
|
||||
lower = backend_record_count(backend_raw)
|
||||
lost = upper == 0 and lower > 0
|
||||
return lost, upper, lower
|
||||
|
||||
|
||||
def classify_real_status(
|
||||
exit_code: int | None,
|
||||
stdout: str,
|
||||
current_status: str | None = None,
|
||||
*,
|
||||
backend_raw: Any = None,
|
||||
) -> str:
|
||||
def classify_real_status(exit_code: int | None, stdout: str, current_status: str | None = None) -> str:
|
||||
if current_status in {"timeout", "held"}:
|
||||
return current_status
|
||||
if exit_code != 0:
|
||||
return "real-error"
|
||||
if payload_indicates_error(stdout):
|
||||
return "real-error"
|
||||
# 上下层比对:下层有数据但上层投影为空 = 投影吃数据,绝不能算通过。
|
||||
if backend_raw is not None:
|
||||
lost, _upper, _lower = compare_layers(stdout, backend_raw)
|
||||
if lost:
|
||||
return "real-error"
|
||||
return "real-ok"
|
||||
|
||||
|
||||
@@ -204,22 +97,6 @@ def _haystack(result: dict[str, Any]) -> str:
|
||||
def classify_failure(result: dict[str, Any]) -> tuple[str, str, str]:
|
||||
"""Return (category, fixability, note) for a real-test result."""
|
||||
status = result.get("status")
|
||||
|
||||
# 上下层比对优先:即便进程 exit 0、无 error 信封、status 被记成 real-ok,只要
|
||||
# 采集到的下层原始后端有数据而上层投影为空,就是投影把数据吃了——判定为
|
||||
# projection-data-loss,属于 shortcut 自身可修的 bug(改投影层的容器 key/下钻)。
|
||||
backend_raw = _backend_payload(result)
|
||||
if backend_raw is not None and result.get("exit_code") in (0, None):
|
||||
lost, upper, lower = compare_layers(result.get("stdout") or "", backend_raw)
|
||||
if lost:
|
||||
return (
|
||||
"projection-data-loss",
|
||||
"cli-projection-fix-needed",
|
||||
f"上层便捷层投影为空({upper} 条),但下层原始后端有数据(约 {lower} 条);"
|
||||
"投影层的容器 key/嵌套下钻与真实响应结构不匹配,把非空数据吃成了空。"
|
||||
"需修投影层解析,并补「喂真实响应结构、断言非空」的单测。",
|
||||
)
|
||||
|
||||
if status == "real-ok":
|
||||
return ("passed", "fixed", "真实后端执行成功。")
|
||||
if status == "timeout":
|
||||
@@ -386,38 +263,6 @@ def summarize_failure_categories(results: list[dict[str, Any]]) -> dict[str, int
|
||||
return dict(sorted(out.items(), key=lambda kv: (-kv[1], kv[0])))
|
||||
|
||||
|
||||
def projection_audit(result: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""投影保真审计:对只读/列表类 shortcut 强制「上下层比对」这条铁律。
|
||||
|
||||
返回 None 表示无需关注;否则返回一个 warning dict 供报告展示:
|
||||
- projection-data-loss:下层有数据、上层空 —— 确定的投影吃数据 bug;
|
||||
- empty-projection-unverified:上层投影为空但缺下层采集 —— 无法证明「本就为空」,
|
||||
必须补采下层原始响应(原始 MCP 响应 / 对应 leaf 命令)再比对,禁止直接判 real-ok。
|
||||
"""
|
||||
stdout = result.get("stdout") or ""
|
||||
upper = count_projection_items(stdout)
|
||||
if upper is None or upper > 0:
|
||||
return None # 非列表型投影,或上层本就有数据 —— 最常见的正常情形。
|
||||
backend_raw = _backend_payload(result)
|
||||
if backend_raw is None:
|
||||
return {
|
||||
"kind": "empty-projection-unverified",
|
||||
"upper_count": upper,
|
||||
"backend_count": None,
|
||||
"note": "便捷层投影为空,但未采集下层原始后端响应,无法证明底层确实无数据;"
|
||||
"按铁律必须补采下层后再比对,不能直接判 real-ok。",
|
||||
}
|
||||
lost, _upper, lower = compare_layers(stdout, backend_raw)
|
||||
if lost:
|
||||
return {
|
||||
"kind": "projection-data-loss",
|
||||
"upper_count": upper,
|
||||
"backend_count": lower,
|
||||
"note": "下层原始后端有数据但上层投影为空:投影层解析与真实响应结构不匹配。",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
_OSS_URL_RE = re.compile(r"https?://[^\s\"'<>]*oss[^\s\"'<>]*", re.IGNORECASE)
|
||||
_ALIBABA_ACCESS_KEY_ID_RE = re.compile(r"\bLTAI[A-Za-z0-9]{12,}\b")
|
||||
_OSS_QUERY_KEY_RE = re.compile(
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-checking tests for scripts/shortcut_real_result.py.
|
||||
|
||||
Run: python3 scripts/shortcut_real_result_test.py
|
||||
|
||||
Focus: the upper/lower layer comparison rules added after the
|
||||
contact +list-roles / oa +list-forms projection-data-loss case, plus a
|
||||
regression guard that pre-existing exit-code / error-envelope behaviour is
|
||||
unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from shortcut_real_result import (
|
||||
backend_record_count,
|
||||
classify_failure,
|
||||
classify_real_status,
|
||||
compare_layers,
|
||||
count_projection_items,
|
||||
payload_indicates_error,
|
||||
projection_audit,
|
||||
)
|
||||
|
||||
# Faithful backend shapes from the real case.
|
||||
GROUPED_ROLES = {
|
||||
"result": [
|
||||
{"groupName": "默认", "labels": [{"labelId": 1, "name": "主管理员"}, {"labelId": 2, "name": "负责人"}]},
|
||||
{"groupName": "职务", "labels": [{"labelId": 3, "name": "财务"}]},
|
||||
]
|
||||
}
|
||||
PROCESS_CODE_LIST = {
|
||||
"result": {
|
||||
"processCodeList": [
|
||||
{"processCode": "PROC-1", "processName": "请假"},
|
||||
{"processCode": "PROC-2", "processName": "加班"},
|
||||
],
|
||||
"totalCount": -1,
|
||||
},
|
||||
"success": True,
|
||||
}
|
||||
|
||||
EMPTY_ROLES_PROJECTION = json.dumps({"count": 0, "roles": []})
|
||||
FULL_ROLES_PROJECTION = json.dumps({"count": 2, "roles": [{"labelId": 1}, {"labelId": 2}]})
|
||||
DETAIL_PROJECTION = json.dumps({"userId": "u1", "name": "张三"})
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
if not cond:
|
||||
raise AssertionError(f"FAIL: {name}")
|
||||
print(f" ok: {name}")
|
||||
|
||||
|
||||
def test_count_projection_items():
|
||||
check("empty roles -> 0", count_projection_items(EMPTY_ROLES_PROJECTION) == 0)
|
||||
check("full roles -> 2", count_projection_items(FULL_ROLES_PROJECTION) == 2)
|
||||
check("detail (no list) -> None", count_projection_items(DETAIL_PROJECTION) is None)
|
||||
check("bare array -> len", count_projection_items("[1,2,3]") == 3)
|
||||
check("empty string -> None", count_projection_items("") is None)
|
||||
|
||||
|
||||
def test_backend_record_count():
|
||||
# backend_record_count is a "has data" proxy = size of the largest single
|
||||
# object array found at any depth (not the flattened total).
|
||||
check("grouped roles has data (>0)", backend_record_count(GROUPED_ROLES) == 2)
|
||||
check("processCodeList count", backend_record_count(PROCESS_CODE_LIST) == 2)
|
||||
check("empty backend -> 0", backend_record_count({"result": []}) == 0)
|
||||
check("error envelope -> 0", backend_record_count({"errorCode": 1, "errorMessage": "x"}) == 0)
|
||||
|
||||
|
||||
def test_compare_layers():
|
||||
lost, upper, lower = compare_layers(EMPTY_ROLES_PROJECTION, GROUPED_ROLES)
|
||||
check("roles: data loss detected", lost and upper == 0 and lower == 2)
|
||||
lost2, _, _ = compare_layers(FULL_ROLES_PROJECTION, GROUPED_ROLES)
|
||||
check("roles: no loss when upper populated", not lost2)
|
||||
lost3, _, lower3 = compare_layers(EMPTY_ROLES_PROJECTION, {"result": []})
|
||||
check("roles: no loss when backend also empty", not lost3 and lower3 == 0)
|
||||
|
||||
|
||||
def test_classify_real_status():
|
||||
# Backward-compat: without backend_raw, empty projection is still real-ok.
|
||||
check(
|
||||
"legacy empty projection stays real-ok",
|
||||
classify_real_status(0, EMPTY_ROLES_PROJECTION) == "real-ok",
|
||||
)
|
||||
# With lower layer that has data, empty projection becomes real-error.
|
||||
check(
|
||||
"empty projection + backend data -> real-error",
|
||||
classify_real_status(0, EMPTY_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-error",
|
||||
)
|
||||
# Populated projection stays ok even with backend.
|
||||
check(
|
||||
"full projection + backend -> real-ok",
|
||||
classify_real_status(0, FULL_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-ok",
|
||||
)
|
||||
# Regression: non-zero exit and error envelope untouched.
|
||||
check("nonzero exit -> real-error", classify_real_status(5, "") == "real-error")
|
||||
check(
|
||||
"error envelope -> real-error",
|
||||
classify_real_status(0, json.dumps({"success": False, "error": "boom"})) == "real-error",
|
||||
)
|
||||
check("timeout preserved", classify_real_status(0, "", "timeout") == "timeout")
|
||||
|
||||
|
||||
def test_classify_failure_projection_loss():
|
||||
result = {
|
||||
"status": "real-ok", # process exited 0, looked fine
|
||||
"exit_code": 0,
|
||||
"service": "contact",
|
||||
"command": "+list-roles",
|
||||
"stdout": EMPTY_ROLES_PROJECTION,
|
||||
"backend_stdout": json.dumps(GROUPED_ROLES),
|
||||
}
|
||||
category, fixability, _ = classify_failure(result)
|
||||
check("projection-data-loss category", category == "projection-data-loss")
|
||||
check("cli fixable", fixability == "cli-projection-fix-needed")
|
||||
|
||||
# No loss when projection is populated.
|
||||
ok_result = dict(result, stdout=FULL_ROLES_PROJECTION)
|
||||
check("populated -> passed", classify_failure(ok_result)[0] == "passed")
|
||||
|
||||
# Regression: a normal real-ok with no backend capture still passes.
|
||||
check(
|
||||
"plain real-ok passes",
|
||||
classify_failure({"status": "real-ok", "exit_code": 0, "stdout": FULL_ROLES_PROJECTION})[0] == "passed",
|
||||
)
|
||||
|
||||
|
||||
def test_projection_audit():
|
||||
# Empty projection, no backend -> must-verify warning.
|
||||
warn = projection_audit({"stdout": EMPTY_ROLES_PROJECTION})
|
||||
check("unverified empty flagged", warn is not None and warn["kind"] == "empty-projection-unverified")
|
||||
|
||||
# Empty projection + backend data -> data loss.
|
||||
warn2 = projection_audit({"stdout": EMPTY_ROLES_PROJECTION, "backend": GROUPED_ROLES})
|
||||
check("data loss flagged", warn2 is not None and warn2["kind"] == "projection-data-loss")
|
||||
|
||||
# Populated projection -> no warning.
|
||||
check("populated -> no warning", projection_audit({"stdout": FULL_ROLES_PROJECTION}) is None)
|
||||
|
||||
# Detail (non-list) projection -> no warning.
|
||||
check("detail -> no warning", projection_audit({"stdout": DETAIL_PROJECTION}) is None)
|
||||
|
||||
|
||||
def test_payload_indicates_error_regression():
|
||||
check("clean payload not error", not payload_indicates_error(FULL_ROLES_PROJECTION))
|
||||
check("errorCode!=0 is error", payload_indicates_error(json.dumps({"errorCode": 1})))
|
||||
check("errorCode==0 not error", not payload_indicates_error(json.dumps({"errorCode": 0})))
|
||||
|
||||
|
||||
def test_record_run_wires_lower_layer():
|
||||
"""End-to-end: record_real_shortcut_run.py must capture the lower layer and
|
||||
flag an exit-0 empty projection as projection-data-loss (not real-ok)."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
recorder = os.path.join(here, "record_real_shortcut_run.py")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
backend = os.path.join(d, "backend.json")
|
||||
out = os.path.join(d, "out.json")
|
||||
# Lower layer has 3 roles nested under result[].labels[].
|
||||
with open(backend, "w") as f:
|
||||
json.dump(GROUPED_ROLES, f)
|
||||
env = dict(os.environ, SHORTCUT_REAL_RESULTS_PATH=out)
|
||||
# Upper command: an empty projection (exit 0, no error envelope).
|
||||
argv = ["python3", "-c", "print('{\"count\": 0, \"roles\": []}')"]
|
||||
p = subprocess.run(
|
||||
["python3", recorder, "--service", "contact", "--command", "+list-roles",
|
||||
"--risk", "read", "--backend-raw", backend, "--"] + argv,
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
check("recorder exits non-zero on data loss", p.returncode == 2)
|
||||
rec = json.loads(p.stdout)
|
||||
check("status flipped to real-error", rec["status"] == "real-error")
|
||||
check("category is projection-data-loss", rec.get("failure_category") == "projection-data-loss")
|
||||
check("projection_audit attached", rec.get("projection_audit", {}).get("kind") == "projection-data-loss")
|
||||
# Raw backend payload must NOT be persisted.
|
||||
persisted = json.load(open(out))
|
||||
blob = json.dumps(persisted, ensure_ascii=False)
|
||||
check("raw lower-layer not persisted", "主管理员" not in blob and "labels" not in blob)
|
||||
|
||||
|
||||
def main():
|
||||
tests = [
|
||||
test_count_projection_items,
|
||||
test_backend_record_count,
|
||||
test_compare_layers,
|
||||
test_classify_real_status,
|
||||
test_classify_failure_projection_loss,
|
||||
test_projection_audit,
|
||||
test_payload_indicates_error_regression,
|
||||
test_record_run_wires_lower_layer,
|
||||
]
|
||||
for t in tests:
|
||||
print(f"{t.__name__}:")
|
||||
t()
|
||||
print(f"\nAll {len(tests)} test groups passed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -74,17 +74,16 @@ cli_version: ">=1.0.15"
|
||||
| 产品 | 用途 | 参考文件 |
|
||||
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
|
||||
| `aiapp` | AI应用:创建/查询/修改AI应用 | [aiapp.md](./references/products/aiapp.md) |
|
||||
| `aisearch` | AI搜问(通用找人首选):按姓名/部门/职位/职责/上级/下级/手机号/工号维度找人,"谁负责 XX/XX 的负责人/某事项/某项目的人"统一走本产品;不含人才池/绩效/职业历程等专项 HR 场景(那些去 `hrbrain`) | [aisearch.md](./references/products/aisearch.md) |
|
||||
| `aisearch` | AI搜问(搜人首选):按姓名/部门/职位/职责/上级/下级/手机号/工号维度找人,"谁负责 XX/XX 的负责人/某事项/某项目的人"统一走本产品 | [aisearch.md](./references/products/aisearch.md) |
|
||||
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
|
||||
| `attendance` | 考勤:打卡结果/打卡流水/考勤组查询/考勤规则/汇总统计/假期类型/假期余额(P0 已落地,部分管理类命令仍属 P1) | [attendance.md](./references/products/attendance.md) |
|
||||
| `calendar` | 日历:日历列表/日程/参与者/附件/响应/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) |
|
||||
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/消息收藏/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) |
|
||||
| `contact` | 通讯录:用户查询/部门/角色/花名册(学历/家庭/银行卡/紧急联系人/合同等基础字段)/离职员工/特别关注,以及创建企业、企业账号和邀请员工;不含职业历程/绩效/人才池(那些去 `hrbrain`) | [contact.md](./references/products/contact.md) |
|
||||
| `contact` | 通讯录:用户查询/部门/角色/花名册/离职员工/特别关注,以及创建企业、企业账号和邀请员工 | [contact.md](./references/products/contact.md) |
|
||||
| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) |
|
||||
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
|
||||
| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论/文件创建/复制/移动/重命名/**删除/导出 docx/权限管理/媒体上传下载** | [doc.md](./references/products/doc.md) |
|
||||
| `drive` | 钉钉云盘:文件列表/元数据/文件夹/上传(两步)/下载 | [drive.md](./references/products/drive.md) |
|
||||
| `hrbrain` | 组织大脑:人才池管理/员工档案专项模块查询(元数据/批量数据/标签/职业历程/绩效)/结构化高级人才搜索(原始条件表达式);区别于 `contact` 的基础通讯录档案与 `aisearch` 的通用语义找人 | [hrbrain.md](./references/products/hrbrain.md) |
|
||||
| `markdown` | 原生 Markdown 文件:读取/创建/全量覆盖/字面量或 RE2 局部替换 | [markdown.md](./references/products/markdown.md) |
|
||||
| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/发言人段落总结/热词/录音控制/成员权限/上传 | [minutes.md](./references/products/minutes.md) |
|
||||
| `oa` | OA审批:待处理/详情/同意/拒绝/撤销/记录/已发起/任务/转交/评论/抄送 | [oa.md](./references/products/oa.md) |
|
||||
@@ -100,17 +99,16 @@ cli_version: ">=1.0.15"
|
||||
|
||||
用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码" → `aiapp`
|
||||
用户提到"目标管理/Agoal/战略解码/经营合约/计分卡/目标模板/周月报提交统计" → `agoal`
|
||||
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`(通用语义找人;若明确涉及人才池/绩效/职业历程/结构化高级条件,去 `hrbrain`)
|
||||
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`
|
||||
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
|
||||
用户提到"考勤/打卡/排班" → `attendance`
|
||||
用户提到"日程/日历/会议室/约会/时间建议" → `calendar`
|
||||
用户提到"群聊/建群/群成员/群管理/发消息/发图片消息/发文件消息/发 Markdown 消息/截图发钉钉/转发消息/引用回复/@我/特别关注消息/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat`
|
||||
用户提到"通讯录/同事/部门/组织架构/子部门/部门多少人/离职员工/离职名单/离职花名册/花名册/基础员工档案(学历/家庭/银行卡/紧急联系人/合同)/角色/主管角色/管理员角色/财务/HR/特别关注/星标联系人/创建企业/企业账号/邀请员工/新员工入职" → `contact`(不含职业历程/绩效/人才池;那些去 `hrbrain`)
|
||||
用户提到"通讯录/同事/部门/组织架构/子部门/部门多少人/离职员工/离职名单/离职花名册/花名册/员工档案/学历/家庭/银行卡/紧急联系人/合同/角色/主管角色/管理员角色/财务/HR/特别关注/星标联系人/创建企业/企业账号/邀请员工/新员工入职" → `contact`
|
||||
用户提到"开发/API/调用错误 文档" → `devdoc`
|
||||
用户提到"DING/紧急消息/电话提醒" → `ding`
|
||||
用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论/文档复制移动" → `doc`
|
||||
用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive`
|
||||
用户提到"人才池/储备干部池/员工档案元数据或批量模块数据/职业历程/绩效记录/员工标签/组织大脑/结构化人才搜索(高级条件表达式)" → `hrbrain`(区别于 `aisearch` 的通用语义找人与 `contact` 的基础通讯录档案)
|
||||
用户提到"原生 Markdown 文件/.md 文件/读取 Markdown 原文/覆盖 Markdown/局部替换 Markdown" → `markdown`
|
||||
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
|
||||
用户提到"邮箱/邮件/发邮件/收邮件/搜邮件/查邮件/邮件草稿/转发邮件/回复邮件/邮件附件/抄送" → `mail`
|
||||
@@ -126,7 +124,6 @@ cli_version: ">=1.0.15"
|
||||
关键区分: report(钉钉日志/日报周报) vs todo(待办任务)
|
||||
关键区分: chat send-by-bot(机器人身份发消息) vs send-by-webhook(自定义机器人Webhook告警)
|
||||
关键区分: doc(在线富文本文档/adoc) vs markdown(原生 .md 纯文本文件) vs drive(通用文件存储与传输)
|
||||
关键区分: contact(基础通讯录档案:学历/家庭/银行卡/紧急联系人/合同/部门角色) vs aisearch person(通用语义找人:谁负责/上级/下级/多维度模糊搜索) vs hrbrain(人才池/员工档案专项模块数据/职业历程/绩效/结构化高级人才搜索)
|
||||
关键区分: oa tasks(审批 taskId,审批/拒绝用) vs oa list-pending(收件箱 processInstanceId,查看用)
|
||||
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# Hrbrain(组织大脑)
|
||||
|
||||
## 产品说明
|
||||
|
||||
Hrbrain 是钉钉组织大脑,提供人才池管理、员工档案查询、人才搜索三大能力,帮助 HR/管理者盘点人才、查询员工履历绩效、按条件检索人员。
|
||||
|
||||
**CLI 前缀**: `dws hrbrain`
|
||||
|
||||
> ⚠️ **权限粒度**:`talent-pool list` 等接口需要账号单独开通"人才池查看权限",即使账号已登录且其他 hrbrain 接口可用,仍可能因该细粒度权限缺失返回 `errorCode=2002`(无人才池查看权限)。
|
||||
|
||||
## 命令总览
|
||||
|
||||
### talent-pool (人才池管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `talent-pool list` | 查询人才池列表 | - | 可选 `--keyword`、`--pool-type`、`--creator`、`--labels`(逗号分隔)、`--page`、`--page-size`(默认 1/20) |
|
||||
| `talent-pool detail` | 获取人才池详情 | `--pool-code` | 根据人才池编码查询 |
|
||||
| `talent-pool employees` | 查询人才池内人员列表 | `--pool-code` | 可选 `--page`、`--page-size`(默认 1/20) |
|
||||
|
||||
### profile (员工档案管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `profile metadata` | 查询员工档案元数据结构 | `--work-no` | 用于构造 `profile query` 的 `--data-queries` |
|
||||
| `profile query` | 按模块批量查询员工档案数据 | `--work-no` `--data-queries` | `--data-queries` 为 JSON 数组,每项含 `modelCode`(档案模块编码)与 `fields`(字段编码列表) |
|
||||
| `profile labels` | 获取员工标签 | `--staff-ids` | `--staff-ids` 为逗号分隔工号列表;可选 `--all-label` |
|
||||
| `profile career` | 查询员工公司内职业历程 | `--work-no` | - |
|
||||
| `profile performance` | 查询员工绩效记录 | `--work-no` | - |
|
||||
|
||||
### search (人才搜索)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `search employees` | 人才搜索 | - | 可选 `--keyword`、`--dept-name`、`--position-name`、`--job-level`、`--pool-code`、`--page`、`--page-size` |
|
||||
| `search employees-structured` | 使用高级条件搜索人员 | `--origin-json` `--fields` | 建议先调用 `search fields` 获取有权限的字段与操作符列表;`--origin-json` 为搜索表达式 JSON,`--fields` 为返回列定义 JSON 数组;可选 `--order-by`(逗号分隔)、`--page`、`--page-size` |
|
||||
| `search fields` | 获取高级搜索字段列表 | - | 获取当前操作人有权限使用的字段,用于构造 `search employees-structured` 的参数 |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"人才池/储备干部池":
|
||||
- 查看列表 → `talent-pool list`
|
||||
- 详情 → `talent-pool detail`
|
||||
- 池内人员 → `talent-pool employees`
|
||||
|
||||
用户说"员工档案/档案数据/员工信息模块":
|
||||
- 元数据结构 → `profile metadata`
|
||||
- 批量查数据 → `profile query`(先 `metadata` 确认字段编码)
|
||||
|
||||
用户说"员工标签/人才标签":
|
||||
- `profile labels`
|
||||
|
||||
用户说"职业历程/职业发展/内部履历":
|
||||
- `profile career`
|
||||
|
||||
用户说"绩效记录/绩效考核历史":
|
||||
- `profile performance`
|
||||
|
||||
用户说"搜人/人才搜索/按条件找人":
|
||||
- 简单条件(关键词/部门/职务/职级)→ `search employees`
|
||||
- 复杂组合条件 → 先 `search fields` 获取字段,再 `search employees-structured`
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# 人才池列表与详情
|
||||
dws hrbrain talent-pool list --page 1 --page-size 20 --format json
|
||||
dws hrbrain talent-pool list --keyword "储备干部" --pool-type TYPE --creator USER_ID --format json
|
||||
dws hrbrain talent-pool detail --pool-code POOL_CODE --format json
|
||||
dws hrbrain talent-pool employees --pool-code POOL_CODE --page 1 --page-size 20 --format json
|
||||
|
||||
# 员工档案:先查元数据确定字段编码,再批量查数据
|
||||
dws hrbrain profile metadata --work-no WORK_NO --format json
|
||||
dws hrbrain profile query --work-no WORK_NO \
|
||||
--data-queries '[{"modelCode":"basic","fields":["name","dept"]}]' \
|
||||
--format json
|
||||
|
||||
# 员工标签、职业历程、绩效
|
||||
dws hrbrain profile labels --staff-ids WORK_NO1,WORK_NO2 --all-label --format json
|
||||
dws hrbrain profile career --work-no WORK_NO --format json
|
||||
dws hrbrain profile performance --work-no WORK_NO --format json
|
||||
|
||||
# 人才搜索:简单条件
|
||||
dws hrbrain search employees --keyword "张三" --page 1 --page-size 20 --format json
|
||||
dws hrbrain search employees --dept-name "技术部" --job-level P7 --pool-code POOL_CODE --format json
|
||||
|
||||
# 人才搜索:高级条件,先获取字段列表
|
||||
dws hrbrain search fields --format json
|
||||
dws hrbrain search employees-structured \
|
||||
--origin-json '{"rules":[{"field":"name","operator":"contains","value":"张"}],"combinator":"and"}' \
|
||||
--fields '[{"label":"姓名","value":"name"}]' \
|
||||
--page 1 --page-size 20 --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `talent-pool list` | `poolCode` | `talent-pool detail` / `talent-pool employees` 的 `--pool-code` |
|
||||
| `talent-pool employees` | `workNo` | `profile *` 系列命令的 `--work-no` |
|
||||
| `profile metadata` | `modelCode`、`fields` | `profile query` 的 `--data-queries` |
|
||||
| `search fields` | 字段 `value`/`label` 列表 | `search employees-structured` 的 `--fields`、`--origin-json` 中的 `field` |
|
||||
| `search employees` | `workNo` | `profile *` 系列命令的 `--work-no` |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `--data-queries`、`--fields`、`--origin-json` 均为 JSON 字符串参数,必须是合法 JSON,否则命令直接报错。
|
||||
- `--staff-ids`、`--labels`、`--order-by` 为逗号分隔字符串,非 JSON 数组。
|
||||
- `--page`/`--page-size` 默认值为 1/20;全局安装的旧版本 `dws` 可能不识别分页 flag,需确认二进制版本已包含 hrbrain 分页支持。
|
||||
- `talent-pool list` 需要账号单独开通人才池查看权限;返回 `errorCode=2002` 时提示用户联系管理员开通权限,而非重试或换 profile。
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
name: dingtalk-hrbrain
|
||||
description: 钉钉组织大脑 Hrbrain。Use when 用户说 人才池/储备干部池/员工档案/职业历程/绩效记录/员工标签/组织大脑/人才搜索。Distinct from dingtalk-contact(通讯录/组织架构)、dingtalk-attendance(考勤)。命令前缀:dws hrbrain。
|
||||
cli_version: ">=1.0.54-0"
|
||||
metadata:
|
||||
category: product
|
||||
stability: experimental
|
||||
requires:
|
||||
bins:
|
||||
- dws
|
||||
---
|
||||
|
||||
# 钉钉 Hrbrain(组织大脑)Skill
|
||||
|
||||
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。全部 dingtalk-* skill 已通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
|
||||
|
||||
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
|
||||
|
||||
<!-- SAFETY_PREAMBLE_INJECT -->
|
||||
|
||||
> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
|
||||
|
||||
> 命令参考:[hrbrain.md](references/hrbrain.md)。
|
||||
|
||||
## 意图表
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "人才池列表 / 储备干部池" | `dws hrbrain talent-pool list` |
|
||||
| "人才池详情" | `dws hrbrain talent-pool detail --pool-code <POOL_CODE>` |
|
||||
| "人才池里有哪些人" | `dws hrbrain talent-pool employees --pool-code <POOL_CODE>` |
|
||||
| "员工档案元数据 / 档案结构" | `dws hrbrain profile metadata --work-no <WORK_NO>` |
|
||||
| "查员工档案数据" | 先 `profile metadata` 确认字段编码,再 `dws hrbrain profile query --work-no <WORK_NO> --data-queries '[...]'` |
|
||||
| "员工标签" | `dws hrbrain profile labels --staff-ids <WORK_NO1,WORK_NO2>` |
|
||||
| "职业历程 / 内部履历" | `dws hrbrain profile career --work-no <WORK_NO>` |
|
||||
| "绩效记录" | `dws hrbrain profile performance --work-no <WORK_NO>` |
|
||||
| "搜人 / 按条件找人(简单)" | `dws hrbrain search employees --keyword <关键词>` |
|
||||
| "搜人(复杂组合条件)" | 先 `dws hrbrain search fields` 获取字段,再 `dws hrbrain search employees-structured --origin-json '{...}' --fields '[...]'` |
|
||||
|
||||
## 权限与约束
|
||||
|
||||
- `talent-pool list` 需要账号单独开通"人才池查看权限",返回 `errorCode=2002` 时提示用户联系管理员开通,不要重试或换 profile。
|
||||
- `--data-queries`、`--fields`、`--origin-json` 必须是合法 JSON 字符串;`--staff-ids`、`--labels`、`--order-by` 是逗号分隔字符串,不是 JSON。
|
||||
@@ -1,65 +0,0 @@
|
||||
# Hrbrain(组织大脑)
|
||||
|
||||
## 产品说明
|
||||
|
||||
Hrbrain 是钉钉组织大脑,提供人才池管理、员工档案查询、人才搜索三大能力。
|
||||
|
||||
**CLI 前缀**: `dws hrbrain`
|
||||
|
||||
## 命令总览
|
||||
|
||||
### talent-pool (人才池管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `talent-pool list` | 查询人才池列表 | - | 可选 `--keyword`、`--pool-type`、`--creator`、`--labels`(逗号分隔)、`--page`、`--page-size` |
|
||||
| `talent-pool detail` | 获取人才池详情 | `--pool-code` | - |
|
||||
| `talent-pool employees` | 查询人才池内人员列表 | `--pool-code` | 可选 `--page`、`--page-size` |
|
||||
|
||||
### profile (员工档案管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `profile metadata` | 查询员工档案元数据结构 | `--work-no` | 用于构造 `profile query` 的 `--data-queries` |
|
||||
| `profile query` | 按模块批量查询员工档案数据 | `--work-no` `--data-queries` | `--data-queries` 为 JSON 数组,每项含 `modelCode`、`fields` |
|
||||
| `profile labels` | 获取员工标签 | `--staff-ids` | 逗号分隔工号列表;可选 `--all-label` |
|
||||
| `profile career` | 查询员工公司内职业历程 | `--work-no` | - |
|
||||
| `profile performance` | 查询员工绩效记录 | `--work-no` | - |
|
||||
|
||||
### search (人才搜索)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `search employees` | 人才搜索(简单条件) | - |
|
||||
| `search employees-structured` | 高级结构化搜索 | `--origin-json` `--fields` |
|
||||
| `search fields` | 获取高级搜索字段列表 | - |
|
||||
|
||||
## 意图判断
|
||||
|
||||
- "人才池/储备干部池" → `talent-pool list/detail/employees`
|
||||
- "员工档案/档案数据" → `profile metadata/query`
|
||||
- "员工标签" → `profile labels`
|
||||
- "职业历程/内部履历" → `profile career`
|
||||
- "绩效记录" → `profile performance`
|
||||
- "搜人/按条件找人" → `search employees`(简单)或 `search fields` + `search employees-structured`(复杂)
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
dws hrbrain talent-pool list --page 1 --page-size 20 --format json
|
||||
dws hrbrain talent-pool detail --pool-code POOL_CODE --format json
|
||||
dws hrbrain talent-pool employees --pool-code POOL_CODE --format json
|
||||
dws hrbrain profile metadata --work-no WORK_NO --format json
|
||||
dws hrbrain profile query --work-no WORK_NO --data-queries '[{"modelCode":"basic","fields":["name","dept"]}]' --format json
|
||||
dws hrbrain profile labels --staff-ids WORK_NO1,WORK_NO2 --format json
|
||||
dws hrbrain profile career --work-no WORK_NO --format json
|
||||
dws hrbrain profile performance --work-no WORK_NO --format json
|
||||
dws hrbrain search employees --keyword "张三" --format json
|
||||
dws hrbrain search fields --format json
|
||||
dws hrbrain search employees-structured --origin-json '{"rules":[{"field":"name","operator":"contains","value":"张"}],"combinator":"and"}' --fields '[{"label":"姓名","value":"name"}]' --format json
|
||||
```
|
||||
|
||||
## 安全规则
|
||||
|
||||
- `--data-queries`、`--fields`、`--origin-json` 必须是合法 JSON;`--staff-ids`、`--labels`、`--order-by` 是逗号分隔字符串。
|
||||
- `talent-pool list` 需要账号单独开通人才池查看权限(`errorCode=2002` 时提示用户联系管理员开通)。
|
||||
+1
-80
@@ -1,7 +1,7 @@
|
||||
[root]
|
||||
runnable: true
|
||||
hidden: false
|
||||
commands: agoal, aisearch, aitable, api, attendance, audit, auth, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, wiki
|
||||
commands: agoal, aisearch, aitable, api, attendance, audit, auth, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, wiki
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=persistent, --client-secret:string|required=false|hidden=false|no-opt=""|scope=persistent, --debug:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --fields:string|required=false|hidden=false|no-opt=""|scope=persistent, -f/--format:string|required=false|hidden=false|no-opt=""|scope=persistent, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=persistent, --mock:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -o/--output:string|required=false|hidden=true|no-opt=""|scope=persistent, --profile:string|required=false|hidden=false|no-opt=""|scope=persistent, --timeout:int|required=false|hidden=false|no-opt=""|scope=persistent, --token:string|required=false|hidden=true|no-opt=""|scope=persistent, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=persistent
|
||||
|
||||
[agoal]
|
||||
@@ -3740,85 +3740,6 @@
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain]
|
||||
runnable: true
|
||||
hidden: false
|
||||
commands: profile, search, talent-pool
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.profile]
|
||||
runnable: true
|
||||
hidden: false
|
||||
commands: career, labels, metadata, performance, query
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.profile.career]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --work-no:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.profile.labels]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --all-label:bool|required=false|hidden=false|no-opt="true"|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --staff-ids:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.profile.metadata]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --work-no:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.profile.performance]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --work-no:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.profile.query]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --data-queries:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --work-no:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.search]
|
||||
runnable: true
|
||||
hidden: false
|
||||
commands: employees, employees-structured, fields
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.search.employees]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dept-name:string|required=false|hidden=false|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --job-level:string|required=false|hidden=false|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --page:int|required=false|hidden=false|no-opt=""|scope=local, --page-size:int|required=false|hidden=false|no-opt=""|scope=local, --pool-code:string|required=false|hidden=false|no-opt=""|scope=local, --position-name:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.search.employees-structured]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --order-by:string|required=false|hidden=false|no-opt=""|scope=local, --origin-json:string|required=false|hidden=false|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --page:int|required=false|hidden=false|no-opt=""|scope=local, --page-size:int|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.search.fields]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.talent-pool]
|
||||
runnable: true
|
||||
hidden: false
|
||||
commands: detail, employees, list
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.talent-pool.detail]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --pool-code:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.talent-pool.employees]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --page:int|required=false|hidden=false|no-opt=""|scope=local, --page-size:int|required=false|hidden=false|no-opt=""|scope=local, --pool-code:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[hrbrain.talent-pool.list]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --creator:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=false|no-opt=""|scope=local, --labels:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --page:int|required=false|hidden=false|no-opt=""|scope=local, --page-size:int|required=false|hidden=false|no-opt=""|scope=local, --pool-type:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[live]
|
||||
runnable: true
|
||||
hidden: false
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChangedTestPackagesPlansProductionReverseDependencies(t *testing.T) {
|
||||
repository := newChangedTestPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
|
||||
|
||||
writeChangedTestPackagesFixture(t, repository, "base/base.go", `package base
|
||||
|
||||
func Value() string {
|
||||
return "changed"
|
||||
}
|
||||
`)
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "change base")
|
||||
|
||||
changed := runChangedTestPackages(t, repository, "changed", baseRef, headRef)
|
||||
if want := []string{"example.com/fixture/base"}; !slices.Equal(changed, want) {
|
||||
t.Fatalf("changed packages = %q, want %q", changed, want)
|
||||
}
|
||||
|
||||
impacted := runChangedTestPackages(t, repository, "list", baseRef, headRef)
|
||||
want := []string{
|
||||
"example.com/fixture/base",
|
||||
"example.com/fixture/dependent",
|
||||
"example.com/fixture/testconsumer",
|
||||
}
|
||||
if !slices.Equal(impacted, want) {
|
||||
t.Fatalf("impacted packages = %q, want %q", impacted, want)
|
||||
}
|
||||
if slices.Contains(impacted, "example.com/fixture/unrelated") {
|
||||
t.Fatalf("impacted packages unexpectedly contain unrelated package: %q", impacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesMapsEmbeddedAssetsToTheirOwner(t *testing.T) {
|
||||
repository := newChangedTestPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
|
||||
|
||||
writeChangedTestPackagesFixture(
|
||||
t,
|
||||
repository,
|
||||
"assetowner/assets/message.json",
|
||||
"{\"message\":\"updated\"}\n",
|
||||
)
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "update embedded asset")
|
||||
|
||||
want := []string{"example.com/fixture/assetowner"}
|
||||
if changed := runChangedTestPackages(t, repository, "changed", baseRef, headRef); !slices.Equal(changed, want) {
|
||||
t.Fatalf("changed packages = %q, want embedded owner %q", changed, want)
|
||||
}
|
||||
if impacted := runChangedTestPackages(t, repository, "list", baseRef, headRef); !slices.Equal(impacted, want) {
|
||||
t.Fatalf("impacted packages = %q, want embedded owner %q", impacted, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesIgnoresDocumentationOnlyDiff(t *testing.T) {
|
||||
repository := newChangedTestPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
|
||||
|
||||
writeChangedTestPackagesFixture(t, repository, "docs/guide.md", "# Updated guide\n")
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "update documentation")
|
||||
|
||||
for _, mode := range []string{"changed", "list"} {
|
||||
if packages := runChangedTestPackages(t, repository, mode, baseRef, headRef); len(packages) != 0 {
|
||||
t.Errorf("%s packages = %q, want no packages", mode, packages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesRejectsMismatchedOrDirtyCheckout(t *testing.T) {
|
||||
t.Run("mismatched head", func(t *testing.T) {
|
||||
repository := newChangedTestPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
|
||||
writeChangedTestPackagesFixture(t, repository, "base/base.go", "package base\n\nconst Value = \"changed\"\n")
|
||||
commitChangedTestPackagesFixture(t, repository, "change base")
|
||||
|
||||
output, err := runChangedTestPackagesFailure(repository, "changed", baseRef, baseRef)
|
||||
if err == nil || !strings.Contains(output, "head does not match the checked-out revision") {
|
||||
t.Fatalf("mismatched HEAD failure = %v, output = %q", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dirty tracked file", func(t *testing.T) {
|
||||
repository := newChangedTestPackagesRepository(t)
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
|
||||
writeChangedTestPackagesFixture(t, repository, "base/base.go", "package base\n\nconst Value = \"dirty\"\n")
|
||||
|
||||
output, err := runChangedTestPackagesFailure(repository, "changed", headRef, headRef)
|
||||
if err == nil || !strings.Contains(output, "requires a clean tracked worktree") {
|
||||
t.Fatalf("dirty worktree failure = %v, output = %q", err, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesFailsClosedWhenPackageGraphIsInvalid(t *testing.T) {
|
||||
repository := newChangedTestPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
|
||||
|
||||
writeChangedTestPackagesFixture(t, repository, "dependent/dependent.go", `package dependent
|
||||
|
||||
import "example.com/fixture/missing"
|
||||
|
||||
func Value() string {
|
||||
return missing.Value()
|
||||
}
|
||||
`)
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "break package graph")
|
||||
|
||||
script := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh")
|
||||
command := exec.Command(script, "list", baseRef, headRef)
|
||||
command.Dir = repository
|
||||
output, err := command.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("invalid package graph unexpectedly succeeded:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), "failed to resolve the module package graph") {
|
||||
t.Fatalf("invalid package graph failure = %q, want fail-closed diagnostic", output)
|
||||
}
|
||||
}
|
||||
|
||||
func newChangedTestPackagesRepository(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
repository := t.TempDir()
|
||||
for path, contents := range map[string]string{
|
||||
"go.mod": `module example.com/fixture
|
||||
|
||||
go 1.22
|
||||
`,
|
||||
"base/base.go": `package base
|
||||
|
||||
func Value() string {
|
||||
return "base"
|
||||
}
|
||||
`,
|
||||
"dependent/dependent.go": `package dependent
|
||||
|
||||
import "example.com/fixture/base"
|
||||
|
||||
func Value() string {
|
||||
return base.Value()
|
||||
}
|
||||
`,
|
||||
"testconsumer/consumer.go": `package testconsumer
|
||||
|
||||
func Value() string {
|
||||
return "consumer"
|
||||
}
|
||||
`,
|
||||
"testconsumer/consumer_test.go": `package testconsumer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"example.com/fixture/base"
|
||||
)
|
||||
|
||||
func TestBaseValue(t *testing.T) {
|
||||
if base.Value() == "" {
|
||||
t.Fatal("base value is empty")
|
||||
}
|
||||
}
|
||||
`,
|
||||
"assetowner/asset.go": `package assetowner
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed assets/message.json
|
||||
var message []byte
|
||||
|
||||
func Message() []byte {
|
||||
return message
|
||||
}
|
||||
`,
|
||||
"assetowner/assets/message.json": "{\"message\":\"initial\"}\n",
|
||||
"unrelated/unrelated.go": `package unrelated
|
||||
|
||||
func Value() string {
|
||||
return "unrelated"
|
||||
}
|
||||
`,
|
||||
"docs/guide.md": "# Guide\n",
|
||||
} {
|
||||
writeChangedTestPackagesFixture(t, repository, path, contents)
|
||||
}
|
||||
|
||||
projectRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve project root: %v", err)
|
||||
}
|
||||
script, err := os.ReadFile(filepath.Join(projectRoot, "scripts", "ci", "changed-test-packages.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read changed package planner: %v", err)
|
||||
}
|
||||
scriptPath := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh")
|
||||
if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil {
|
||||
t.Fatalf("create script directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(scriptPath, script, 0o755); err != nil {
|
||||
t.Fatalf("copy changed package planner: %v", err)
|
||||
}
|
||||
|
||||
runChangedTestPackagesCommand(t, repository, "git", "init", "-q", "-b", "main")
|
||||
runChangedTestPackagesCommand(t, repository, "git", "config", "user.name", "DWS CI")
|
||||
runChangedTestPackagesCommand(t, repository, "git", "config", "user.email", "dws-ci@example.invalid")
|
||||
return repository
|
||||
}
|
||||
|
||||
func writeChangedTestPackagesFixture(t *testing.T, repository, path, contents string) {
|
||||
t.Helper()
|
||||
|
||||
fullPath := filepath.Join(repository, filepath.FromSlash(path))
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
|
||||
t.Fatalf("create directory for %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(fullPath, []byte(contents), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func commitChangedTestPackagesFixture(t *testing.T, repository, message string) string {
|
||||
t.Helper()
|
||||
|
||||
runChangedTestPackagesCommand(t, repository, "git", "add", ".")
|
||||
runChangedTestPackagesCommand(t, repository, "git", "commit", "-q", "-m", message)
|
||||
return strings.TrimSpace(runChangedTestPackagesCommand(t, repository, "git", "rev-parse", "HEAD"))
|
||||
}
|
||||
|
||||
func runChangedTestPackages(t *testing.T, repository, mode, baseRef, headRef string) []string {
|
||||
t.Helper()
|
||||
|
||||
output := runChangedTestPackagesCommand(
|
||||
t,
|
||||
repository,
|
||||
filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh"),
|
||||
mode,
|
||||
baseRef,
|
||||
headRef,
|
||||
)
|
||||
return strings.Fields(output)
|
||||
}
|
||||
|
||||
func runChangedTestPackagesFailure(repository, mode, baseRef, headRef string) (string, error) {
|
||||
script := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh")
|
||||
command := exec.Command(script, mode, baseRef, headRef)
|
||||
command.Dir = repository
|
||||
output, err := command.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func runChangedTestPackagesCommand(t *testing.T, directory, name string, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
command := exec.Command(name, args...)
|
||||
command.Dir = directory
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, output)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
@@ -434,52 +434,11 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
`COVERAGE_TARGET: "100"`,
|
||||
`COVERAGE_ENFORCE_OVERALL: "false"`,
|
||||
`COVERAGE_OVERALL_TOLERANCE: "0"`,
|
||||
`additional_profile=coverage-shortcut.txt`,
|
||||
`COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt`,
|
||||
"run: make test-plan",
|
||||
"run: make format-check",
|
||||
"./scripts/ci/test-packages.sh list",
|
||||
`package_output="$(./scripts/ci/test-packages.sh list`,
|
||||
"docs_only: ${{ steps.classify.outputs.docs_only }}",
|
||||
"full_suite: ${{ steps.classify.outputs.full_suite }}",
|
||||
"classifyFiles(files.length < 300)",
|
||||
"platformSensitive =",
|
||||
"files.some(isNativeGoChange)",
|
||||
"paths.some(isEditionSensitive)",
|
||||
`/_(darwin|windows|linux|unix)(?:_[a-z0-9]+)*\.go$/`,
|
||||
"filename.startsWith('build/')",
|
||||
"filename === '.github/actionlint.yaml'",
|
||||
"filename.startsWith('scripts/')",
|
||||
"filename.startsWith('verify/')",
|
||||
"filename === 'test/fixtures/cli-interface-baseline.txt'",
|
||||
"filename.startsWith('internal/interfacesnapshot/')",
|
||||
"filename.startsWith('internal/cobracmd/')",
|
||||
"filename.startsWith('internal/i18n/')",
|
||||
"filename.startsWith('pkg/cmdutil/')",
|
||||
"filename === 'skills_embed.go'",
|
||||
"filename.startsWith('test/mock_mcp/')",
|
||||
"name: Test (changed packages)",
|
||||
"changed-test-packages.sh",
|
||||
"Verify authoritative synthetic merge",
|
||||
`test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA"`,
|
||||
`test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA"`,
|
||||
`echo "TEST_HEAD_REF=$(git rev-parse HEAD)"`,
|
||||
"list \"$TEST_BASE_REF\" \"$TEST_HEAD_REF\"",
|
||||
"needs.lint.outputs.full_suite != 'true'",
|
||||
`name: "Test (race: ${{ matrix.shard }})"`,
|
||||
"name: Test (workflow and release contracts)",
|
||||
"needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.release_sensitive == 'true'",
|
||||
"needs.lint.outputs.full_suite == 'true'",
|
||||
"-coverpkg=\"$coverpkg\"",
|
||||
"changed \"$COVERAGE_BASE_REF\" \"$COVERAGE_HEAD_REF\"",
|
||||
"list \"$COVERAGE_BASE_REF\" \"$COVERAGE_HEAD_REF\"",
|
||||
"current_expected=success",
|
||||
"supporting_expected=skipped",
|
||||
"baseline_expected=success",
|
||||
"Validate scoped policy",
|
||||
"Interface Integrity is unaffected by this classified revision.",
|
||||
"CLI Smoke is unaffected by this classified revision.",
|
||||
"Mock MCP is unaffected by this classified revision.",
|
||||
"Edition is unaffected by this classified revision.",
|
||||
} {
|
||||
if !strings.Contains(admission, want) {
|
||||
t.Errorf("Code Admission workflow missing contract %q", want)
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestGitHubScriptWorkflowJavaScriptParses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to syntax-check actions/github-script steps")
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
paths, err := filepath.Glob(filepath.Join(root, ".github", "workflows", "*.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob workflows: %v", err)
|
||||
}
|
||||
|
||||
checked := 0
|
||||
for _, path := range paths {
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read %s: %v", path, readErr)
|
||||
}
|
||||
var workflow any
|
||||
if unmarshalErr := yaml.Unmarshal(data, &workflow); unmarshalErr != nil {
|
||||
t.Fatalf("parse %s: %v", path, unmarshalErr)
|
||||
}
|
||||
var scripts []string
|
||||
collectGitHubScripts(workflow, &scripts)
|
||||
for index, script := range scripts {
|
||||
checked++
|
||||
source := fmt.Sprintf(
|
||||
"async function workflowScript%d() {\n%s\n}\n",
|
||||
index,
|
||||
script,
|
||||
)
|
||||
scriptPath := filepath.Join(
|
||||
t.TempDir(),
|
||||
fmt.Sprintf("%s-%d.js", filepath.Base(path), index),
|
||||
)
|
||||
if writeErr := os.WriteFile(scriptPath, []byte(source), 0o600); writeErr != nil {
|
||||
t.Fatalf("write syntax fixture for %s: %v", path, writeErr)
|
||||
}
|
||||
command := exec.Command(node, "--check", scriptPath)
|
||||
if output, runErr := command.CombinedOutput(); runErr != nil {
|
||||
t.Errorf(
|
||||
"github-script syntax failed for %s step %d: %v\n%s",
|
||||
path,
|
||||
index,
|
||||
runErr,
|
||||
output,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no actions/github-script steps were found")
|
||||
}
|
||||
}
|
||||
|
||||
func collectGitHubScripts(value any, scripts *[]string) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
uses, _ := typed["uses"].(string)
|
||||
if strings.HasPrefix(uses, "actions/github-script@") {
|
||||
if with, ok := typed["with"].(map[string]any); ok {
|
||||
if script, ok := with["script"].(string); ok {
|
||||
*scripts = append(*scripts, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, child := range typed {
|
||||
collectGitHubScripts(child, scripts)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
collectGitHubScripts(child, scripts)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -698,11 +697,11 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
|
||||
immutableCall = `"GET /repos/{owner}/{repo}/immutable-releases"`
|
||||
governanceID = `github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}`
|
||||
)
|
||||
if got := strings.Count(workflow, checksCall); got != 3 {
|
||||
t.Fatalf("release workflow Checks API call count = %d, want one tag check, one preflight check, and one Formula-parent check", got)
|
||||
if got := strings.Count(workflow, checksCall); got != 2 {
|
||||
t.Fatalf("release workflow Checks API call count = %d, want one tag check and one preflight check", got)
|
||||
}
|
||||
if got := strings.Count(workflow, paginatedCall); got != 3 {
|
||||
t.Fatalf("release workflow paginated Checks API call count = %d, want one tag check, one preflight check, and one Formula-parent check", got)
|
||||
if got := strings.Count(workflow, paginatedCall); got != 2 {
|
||||
t.Fatalf("release workflow paginated Checks API call count = %d, want one tag check and one preflight check", got)
|
||||
}
|
||||
if got := strings.Count(workflow, immutableCall); got != 2 {
|
||||
t.Fatalf("release workflow immutable governance call count = %d, want one tag check and one preflight check", got)
|
||||
@@ -765,7 +764,7 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
|
||||
t,
|
||||
sections["tag"],
|
||||
" - name: Require immutable releases governance\n",
|
||||
"\n - name: Require successful beta delivery before stable promotion\n",
|
||||
"\n - name: Verify Homebrew PR automation permission\n",
|
||||
)
|
||||
for name, section := range map[string]string{
|
||||
"sealed Code Admission recheck": tagChecks,
|
||||
@@ -859,18 +858,9 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
}
|
||||
}
|
||||
admission := strings.Index(preflight, "Require successful Code Admission contexts on the preflight commit")
|
||||
immutableGovernance := strings.Index(preflight, "Require immutable releases governance")
|
||||
if admission == -1 || immutableGovernance == -1 || admission > immutableGovernance {
|
||||
t.Error("governance preflight must validate all exact Code Admission contexts before immutable-release governance")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"HOMEBREW_PR_TOKEN",
|
||||
"verify-homebrew-pr-token.sh",
|
||||
"DWS_TAP_PR_",
|
||||
} {
|
||||
if strings.Contains(preflight, forbidden) {
|
||||
t.Errorf("governance preflight must not retain obsolete Homebrew PR machinery %q", forbidden)
|
||||
}
|
||||
homebrewCanary := strings.Index(preflight, "Verify Homebrew PR automation permission")
|
||||
if admission == -1 || homebrewCanary == -1 || admission > homebrewCanary {
|
||||
t.Error("governance preflight must validate all exact Code Admission contexts before exposing Homebrew credentials")
|
||||
}
|
||||
|
||||
mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n")
|
||||
@@ -971,46 +961,6 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) {
|
||||
t.Errorf("cloud release seal is missing %q", required)
|
||||
}
|
||||
}
|
||||
createRef := strings.Index(seal, "github.rest.git.createRef")
|
||||
visibilityRead := strings.LastIndex(seal, "github.rest.git.getRef")
|
||||
if createRef == -1 || visibilityRead == -1 || visibilityRead < createRef {
|
||||
t.Fatal("cloud release seal must confirm tag-ref visibility after creating the ref")
|
||||
}
|
||||
postCreate := seal[createRef:]
|
||||
for _, required := range []string{
|
||||
"error.status === 404",
|
||||
"error.status === 429",
|
||||
"error.status >= 500",
|
||||
"setTimeout",
|
||||
`.data.object.type !== "tag"`,
|
||||
"!== expectedTagObject",
|
||||
".data.tag !== version",
|
||||
".data.object.sha !== commit",
|
||||
".data.message !==",
|
||||
} {
|
||||
if !strings.Contains(seal, required) {
|
||||
t.Errorf("post-create tag verification is missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Count(postCreate, "github.rest.git.getRef") < 2 {
|
||||
t.Error("post-create tag handling must both adopt an exact uncertain write and confirm final ref visibility")
|
||||
}
|
||||
if !regexp.MustCompile(`(?m)const \w*[Rr]etry\w*[Dd]elays\w* = \[(?:0,?\s*){1}[\d,\s]+\];`).MatchString(seal) {
|
||||
t.Error("post-create tag verification must use a bounded retry policy")
|
||||
}
|
||||
for _, required := range []string{
|
||||
"`${version} tag ref`",
|
||||
"() => github.rest.git.getRef",
|
||||
} {
|
||||
if !strings.Contains(postCreate, required) {
|
||||
t.Errorf("the bounded visibility retry must be used after createRef: missing %q", required)
|
||||
}
|
||||
}
|
||||
retryLoop := strings.LastIndex(seal[:createRef], "for (const delayMs of")
|
||||
retrySleep := strings.LastIndex(seal[:createRef], "await sleep(delayMs)")
|
||||
if retryLoop == -1 || retrySleep == -1 || retryLoop > retrySleep {
|
||||
t.Error("the bounded visibility retry must be used after createRef, not only declared")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"actions/checkout",
|
||||
"github.rest.git.updateRef",
|
||||
@@ -1231,7 +1181,7 @@ func TestReleaseWorkflowRequiresOSSOnlyWhenMirrorIsEnabled(t *testing.T) {
|
||||
func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n governance-preflight:\n")
|
||||
dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n authorize-recovery:\n")
|
||||
start := strings.Index(workflow, " repair-channel:\n")
|
||||
if start == -1 {
|
||||
t.Fatal("release workflow is missing the channel repair job")
|
||||
@@ -1399,6 +1349,10 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
||||
"workflow_dispatch must select exactly one release mode",
|
||||
"release recovery confirmation must equal the exact version",
|
||||
"recover_release_nonce must be bound to the release commit",
|
||||
"environment: release-recovery",
|
||||
"prevent_self_review !== true",
|
||||
"protected_branches !== true",
|
||||
"can_admins_bypass !== false",
|
||||
`run.path !== ".github/workflows/release.yml"`,
|
||||
`const expectedEvent = failedByCloud ? "workflow_dispatch" : "push"`,
|
||||
`run.event !== expectedEvent`,
|
||||
@@ -1428,16 +1382,6 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
||||
t.Errorf("release recovery contract is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"authorize-recovery:",
|
||||
"environment: release-recovery",
|
||||
"needs.authorize-recovery",
|
||||
"AUTHORIZE_RECOVERY_RESULT",
|
||||
} {
|
||||
if strings.Contains(workflow, forbidden) {
|
||||
t.Errorf("machine-verified release recovery must not wait on manual approval %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
sections := map[string]string{
|
||||
"release": releaseWorkflowSection(t, workflow, " release:\n", "\n verify-darwin-signatures:\n"),
|
||||
@@ -1467,49 +1411,6 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowRerunAdoptsOnlyItsExactSeal(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
sealStart := strings.Index(workflow, " seal-release:\n")
|
||||
if sealStart == -1 {
|
||||
t.Fatal("release workflow is missing the cloud seal job")
|
||||
}
|
||||
seal := workflow[sealStart:]
|
||||
|
||||
for _, required := range []string{
|
||||
`const currentAttempt = Number(process.env.GITHUB_RUN_ATTEMPT)`,
|
||||
`Release-Run: ${context.runId}`,
|
||||
`Release-Run-Attempt: ${attempt}`,
|
||||
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
|
||||
`Workflow-Commit: ${context.sha}`,
|
||||
`Allocation-Fingerprint: ${expectedFingerprint}`,
|
||||
`ref.ref === ` + "`refs/tags/${version}`",
|
||||
`existingRef = await github.rest.git.getRef`,
|
||||
`sealedAttempt > currentAttempt`,
|
||||
`.data.message !== messageForAttempt(sealedAttempt)`,
|
||||
`if (branchMoved)`,
|
||||
`if (!existingRef)`,
|
||||
`basehead: ` + "`${commit}...${currentBranchCommit}`",
|
||||
`["ahead", "identical"].includes(comparison.data.status)`,
|
||||
`if (existingRef)`,
|
||||
`continuing failed jobs without recovery approval`,
|
||||
} {
|
||||
if !strings.Contains(seal, required) {
|
||||
t.Errorf("same-run release rerun exact-seal contract is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"github.rest.git.updateRef",
|
||||
"github.rest.git.deleteRef",
|
||||
"environment: release-recovery",
|
||||
"needs.authorize-recovery",
|
||||
} {
|
||||
if strings.Contains(seal, forbidden) {
|
||||
t.Errorf("same-run exact seal reuse must not mutate the tag or require approval: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowDraftLifecycleUsesOneReleaseID(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
@@ -1646,7 +1547,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation
|
||||
name: "release contract",
|
||||
start: " release-contract:\n",
|
||||
end: "\n release:\n",
|
||||
condition: `if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}`,
|
||||
condition: `if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}`,
|
||||
},
|
||||
{
|
||||
name: "build",
|
||||
@@ -1699,6 +1600,7 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
"name: Release delivery gate",
|
||||
`if: ${{ !cancelled() }}`,
|
||||
"- dispatch-contract",
|
||||
"- authorize-recovery",
|
||||
"- governance-preflight",
|
||||
"- release-contract",
|
||||
"- release-validation",
|
||||
@@ -1737,16 +1639,6 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
t.Errorf("release delivery gate is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"- authorize-recovery",
|
||||
"AUTHORIZE_RECOVERY_RESULT",
|
||||
"needs.authorize-recovery",
|
||||
"require_result authorize-recovery",
|
||||
} {
|
||||
if strings.Contains(gate, forbidden) {
|
||||
t.Errorf("release delivery gate must not retain recovery approval dependency %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
|
||||
@@ -1948,10 +1840,10 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
|
||||
"Publish or reuse immutable GitHub Release",
|
||||
"gh release upload",
|
||||
"Publish missing version to npm channel",
|
||||
"Publish stable Homebrew formula",
|
||||
"Publish beta Homebrew formula",
|
||||
"Open stable Homebrew formula PR",
|
||||
"Open beta Homebrew formula PR",
|
||||
"DingTalk-Real-AI/dingtalk-workspace-cli.git",
|
||||
"secrets.GITHUB_TOKEN",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
} {
|
||||
if !strings.Contains(publishSection, required) {
|
||||
t.Errorf("post-verification publication stage is missing %q", required)
|
||||
@@ -1959,7 +1851,7 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowPublishesStableHomebrewFormulaDirectly(t *testing.T) {
|
||||
func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
||||
@@ -1971,45 +1863,61 @@ func TestReleaseWorkflowPublishesStableHomebrewFormulaDirectly(t *testing.T) {
|
||||
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
|
||||
}
|
||||
workflow := string(data)
|
||||
for _, forbidden := range []string{
|
||||
if strings.Contains(workflow, "pull-requests: write") {
|
||||
t.Fatal("the built-in GITHUB_TOKEN must not receive pull-request write permission")
|
||||
}
|
||||
for _, required := range []string{
|
||||
"Verify Homebrew PR automation permission",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
"verify-homebrew-pr-token.sh",
|
||||
"DWS_TAP_PR_REPOSITORY",
|
||||
"DWS_TAP_PR_BRANCH",
|
||||
"Open stable Homebrew formula PR",
|
||||
"Open beta Homebrew formula PR",
|
||||
"--canary",
|
||||
"HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities",
|
||||
} {
|
||||
if strings.Contains(workflow, forbidden) {
|
||||
t.Errorf("release workflow must not retain Homebrew PR machinery %q", forbidden)
|
||||
if !strings.Contains(workflow, required) {
|
||||
t.Errorf("release workflow is missing Homebrew PR token preflight %q", required)
|
||||
}
|
||||
}
|
||||
|
||||
start := strings.Index(workflow, "- name: Publish stable Homebrew formula")
|
||||
if start == -1 {
|
||||
t.Fatal("release workflow is missing direct stable Homebrew publication")
|
||||
if got := strings.Count(workflow, "Verify Homebrew PR automation permission"); got != 2 {
|
||||
t.Errorf("Homebrew PR permission preflight count = %d, want one default-branch preflight and one tag contract", got)
|
||||
}
|
||||
end := strings.Index(workflow[start:], "- name: Publish beta Homebrew formula")
|
||||
tagContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n")
|
||||
tagAdmission := strings.Index(tagContract, "Require successful Code Admission contexts on the sealed commit")
|
||||
tagHomebrew := strings.Index(tagContract, "Verify Homebrew PR automation permission")
|
||||
if tagAdmission == -1 || tagHomebrew == -1 || tagAdmission > tagHomebrew {
|
||||
t.Error("tag contract must validate all exact Code Admission contexts before exposing Homebrew credentials")
|
||||
}
|
||||
|
||||
start := strings.Index(workflow, "- name: Open stable Homebrew formula PR")
|
||||
if start == -1 {
|
||||
t.Fatal("release workflow is missing the stable Homebrew PR step")
|
||||
}
|
||||
end := strings.Index(workflow[start:], "- name: Open beta Homebrew formula PR")
|
||||
if end == -1 {
|
||||
t.Fatal("release workflow is missing direct beta Homebrew publication after the stable step")
|
||||
t.Fatal("release workflow is missing the beta Homebrew PR step after the stable step")
|
||||
}
|
||||
section := workflow[start : start+end]
|
||||
for _, required := range []string{
|
||||
"github.repository_owner == 'DingTalk-Real-AI'",
|
||||
"needs.release-contract.outputs.channel == 'stable'",
|
||||
"scripts/release/publish-homebrew-formula.sh",
|
||||
"./scripts/release/publish-homebrew-formula.sh",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
"DWS_TAP_PR_REPOSITORY",
|
||||
"automation/homebrew-${{ needs.release-contract.outputs.release_version }}",
|
||||
} {
|
||||
if !strings.Contains(section, required) {
|
||||
t.Errorf("direct stable Homebrew publication is missing %q", required)
|
||||
t.Errorf("Homebrew publication step is missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(section, "secrets.GITHUB_TOKEN") {
|
||||
t.Error("Homebrew Formula PRs must use the dedicated token so their CI is triggered")
|
||||
}
|
||||
stableNPM := strings.Index(workflow, "- name: Publish missing version to npm channel")
|
||||
if stableNPM == -1 || start > stableNPM {
|
||||
t.Fatal("Homebrew publication must run before npm so a failure is safely rerunnable")
|
||||
t.Fatal("Homebrew PR creation must run before npm so a failure is safely rerunnable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowPublishesBetaHomebrewFormulaDirectly(t *testing.T) {
|
||||
func TestReleaseWorkflowOpensVersionedHomebrewPRForBetaTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
|
||||
@@ -2022,9 +1930,9 @@ func TestReleaseWorkflowPublishesBetaHomebrewFormulaDirectly(t *testing.T) {
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
start := strings.Index(workflow, "- name: Publish beta Homebrew formula")
|
||||
start := strings.Index(workflow, "- name: Open beta Homebrew formula PR")
|
||||
if start == -1 {
|
||||
t.Fatal("release workflow is missing direct beta Homebrew publication")
|
||||
t.Fatal("release workflow is missing the beta Homebrew PR step")
|
||||
}
|
||||
end := strings.Index(workflow[start:], "- name: Reverify exact immutable npm package")
|
||||
if end == -1 {
|
||||
@@ -2037,108 +1945,14 @@ func TestReleaseWorkflowPublishesBetaHomebrewFormulaDirectly(t *testing.T) {
|
||||
"dist/homebrew/dingtalk-workspace-cli-beta.rb",
|
||||
"Formula/dingtalk-workspace-cli-beta.rb",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
"automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}",
|
||||
} {
|
||||
if !strings.Contains(section, required) {
|
||||
t.Errorf("direct beta Homebrew publication is missing %q", required)
|
||||
t.Errorf("beta Homebrew PR step is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"DWS_TAP_PR_REPOSITORY",
|
||||
"DWS_TAP_PR_BRANCH",
|
||||
"automation/homebrew-beta-",
|
||||
} {
|
||||
if strings.Contains(section, forbidden) {
|
||||
t.Errorf("direct beta Homebrew publication must not open a PR: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
|
||||
if !strings.Contains(publishJob, "checks: write") {
|
||||
t.Fatal("Formula-only delivery must receive checks: write in the already write-capable publication job")
|
||||
}
|
||||
|
||||
for _, required := range []string{
|
||||
"id: homebrew-stable",
|
||||
"id: homebrew-beta",
|
||||
`FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}`,
|
||||
`FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}`,
|
||||
} {
|
||||
if !strings.Contains(publishJob, required) {
|
||||
t.Errorf("Homebrew publisher output wiring is missing %q", required)
|
||||
}
|
||||
}
|
||||
|
||||
seal := releaseWorkflowSection(
|
||||
t,
|
||||
publishJob,
|
||||
" - name: Seal Formula-only Code Admission contexts\n",
|
||||
"\n - name: Reverify exact immutable npm package\n",
|
||||
)
|
||||
for _, required := range []string{
|
||||
`const commit = process.env.FORMULA_COMMIT`,
|
||||
`commitResponse.data.sha === commit`,
|
||||
`commitResponse.data.parents.length === 1`,
|
||||
`commitResponse.data.commit.message === expectedMessage`,
|
||||
`commitResponse.data.author?.login === "github-actions[bot]"`,
|
||||
`commitResponse.data.committer?.login === "github-actions[bot]"`,
|
||||
`files.length === 1`,
|
||||
`files[0].filename === formulaPath`,
|
||||
`["added", "modified"].includes(files[0].status)`,
|
||||
`const parent = commitResponse.data.parents[0].sha`,
|
||||
`github.rest.checks.listForRef`,
|
||||
`ref: parent`,
|
||||
`run.head_sha !== parent`,
|
||||
`run.app?.slug !== "github-actions"`,
|
||||
`run.conclusion !== "success"`,
|
||||
`github.rest.repos.getContent`,
|
||||
`path: formulaPath`,
|
||||
`ref: commit`,
|
||||
`actualFormula.equals(expectedFormula)`,
|
||||
`github.rest.repos.compareCommitsWithBasehead`,
|
||||
`basehead: ` + "`${commit}...${branch.data.commit.sha}`",
|
||||
`["ahead", "identical"].includes(comparison.data.status)`,
|
||||
`github.rest.checks.create`,
|
||||
`head_sha: commit`,
|
||||
`status: "completed"`,
|
||||
`conclusion: "success"`,
|
||||
} {
|
||||
if !strings.Contains(seal, required) {
|
||||
t.Errorf("Formula-only Code Admission sealing is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, context := range expectedReleaseAdmissionContexts {
|
||||
if strings.Count(seal, fmt.Sprintf("%q", context)) != 1 {
|
||||
t.Errorf("Formula-only Code Admission must derive exactly one %q context from the reviewed list", context)
|
||||
}
|
||||
}
|
||||
if strings.Count(seal, "github.rest.checks.create") != 1 {
|
||||
t.Error("Formula-only checks must be created only by the single verified context loop")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"head_sha: context.sha",
|
||||
"head_sha: parent",
|
||||
"head_sha: branch.data.commit.sha",
|
||||
} {
|
||||
if strings.Contains(seal, forbidden) {
|
||||
t.Errorf("Formula-only Code Admission must not mark an unverified head green: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
createCheck := strings.Index(seal, "github.rest.checks.create")
|
||||
for name, marker := range map[string]string{
|
||||
"single-parent Formula-only identity": "const exactFormulaCommit",
|
||||
"successful parent contexts": "invalidParentContexts.length > 0",
|
||||
"byte-for-byte Formula content": "actualFormula.equals(expectedFormula)",
|
||||
"default-branch containment": `["ahead", "identical"].includes(comparison.data.status)`,
|
||||
} {
|
||||
index := strings.Index(seal, marker)
|
||||
if index == -1 || createCheck == -1 || index > createCheck {
|
||||
t.Errorf("%s must be verified before any success check is created", name)
|
||||
}
|
||||
if strings.Contains(section, "secrets.GITHUB_TOKEN") {
|
||||
t.Error("Homebrew beta Formula PRs must use the dedicated token so their CI is triggered")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPublishHomebrewFormulaPublishesDirectlyWithoutPR(t *testing.T) {
|
||||
func TestPublishHomebrewFormulaPushesUpdatedFormula(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "publish-homebrew-formula.sh"))
|
||||
@@ -23,22 +23,12 @@ func TestPublishHomebrewFormulaPublishesDirectlyWithoutPR(t *testing.T) {
|
||||
|
||||
sourceFormula := filepath.Join(root, "dingtalk-workspace-cli.rb")
|
||||
mustWriteFile(t, sourceFormula, []byte("class DingtalkWorkspaceCli < Formula\n desc \"DingTalk Workspace CLI\"\nend\n"), 0o644)
|
||||
publishOutput := filepath.Join(root, "publish-output")
|
||||
|
||||
fakeBin := filepath.Join(root, "bin")
|
||||
ghLog := filepath.Join(root, "gh.log")
|
||||
mustWriteFile(t, filepath.Join(fakeBin, "gh"), []byte(`#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$GH_LOG"
|
||||
exit 97
|
||||
`), 0o755)
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||
"GH_LOG="+ghLog,
|
||||
"DWS_TAP_REPO_URL="+remoteDir,
|
||||
"DWS_TAP_BRANCH=main",
|
||||
"DWS_FORMULA_SOURCE="+sourceFormula,
|
||||
"GITHUB_OUTPUT="+publishOutput,
|
||||
"DWS_GIT_NAME=DWS Bot",
|
||||
"DWS_GIT_EMAIL=dws@example.com",
|
||||
)
|
||||
@@ -60,28 +50,6 @@ exit 97
|
||||
if string(got) != "class DingtalkWorkspaceCli < Formula\n desc \"DingTalk Workspace CLI\"\nend\n" {
|
||||
t.Fatalf("published formula = %q", string(got))
|
||||
}
|
||||
publishedCommit := strings.TrimSpace(mustOutput(t, cloneDir, "git", "rev-parse", "HEAD"))
|
||||
result, err := os.ReadFile(publishOutput)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(publish output) error = %v", err)
|
||||
}
|
||||
wantResult := "formula_changed=true\npublished_commit=" + publishedCommit + "\n"
|
||||
if string(result) != wantResult {
|
||||
t.Fatalf("publish result = %q, want %q", result, wantResult)
|
||||
}
|
||||
parentLine := strings.Fields(mustOutput(t, cloneDir, "git", "rev-list", "--parents", "-n", "1", "HEAD"))
|
||||
if len(parentLine) != 2 {
|
||||
t.Fatalf("published Formula commit parent fields = %v, want exactly one parent", parentLine)
|
||||
}
|
||||
changedPaths := strings.TrimSpace(mustOutput(t, cloneDir, "git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"))
|
||||
if changedPaths != "Formula/dingtalk-workspace-cli.rb" {
|
||||
t.Fatalf("published Formula commit changed %q", changedPaths)
|
||||
}
|
||||
if calls, err := os.ReadFile(ghLog); err == nil {
|
||||
t.Fatalf("direct Homebrew publication unexpectedly invoked gh:\n%s", calls)
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("ReadFile(gh log) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishHomebrewFormulaSkipsWhenFormulaUnchanged(t *testing.T) {
|
||||
@@ -95,27 +63,11 @@ func TestPublishHomebrewFormulaSkipsWhenFormulaUnchanged(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
remoteDir := filepath.Join(root, "tap.git")
|
||||
mustRun(t, root, "git", "init", "--bare", remoteDir)
|
||||
seedTapRepo(t, remoteDir, "main", "class OldFormula < Formula\nend\n")
|
||||
initialFormula := "class DingtalkWorkspaceCli < Formula\n desc \"DingTalk Workspace CLI\"\nend\n"
|
||||
|
||||
historyDir := filepath.Join(root, "history")
|
||||
mustRun(t, root, "git", "clone", "--branch", "main", remoteDir, historyDir)
|
||||
mustRun(t, historyDir, "git", "config", "user.name", "DWS Bot")
|
||||
mustRun(t, historyDir, "git", "config", "user.email", "dws@example.com")
|
||||
mustWriteFile(t, filepath.Join(historyDir, "Formula", "dingtalk-workspace-cli.rb"), []byte(initialFormula), 0o644)
|
||||
mustRun(t, historyDir, "git", "add", "Formula/dingtalk-workspace-cli.rb")
|
||||
mustRun(t, historyDir, "git", "commit", "-m", "chore: update formula for v1.2.3 [skip ci]")
|
||||
formulaCommit := strings.TrimSpace(mustOutput(t, historyDir, "git", "rev-parse", "HEAD"))
|
||||
mustRun(t, historyDir, "git", "config", "user.name", "Another Maintainer")
|
||||
mustRun(t, historyDir, "git", "config", "user.email", "maintainer@example.com")
|
||||
mustWriteFile(t, filepath.Join(historyDir, "README.md"), []byte("unrelated main update\n"), 0o644)
|
||||
mustRun(t, historyDir, "git", "add", "README.md")
|
||||
mustRun(t, historyDir, "git", "commit", "-m", "docs: unrelated main update")
|
||||
mustRun(t, historyDir, "git", "push", "origin", "main")
|
||||
seedTapRepo(t, remoteDir, "main", initialFormula)
|
||||
|
||||
sourceFormula := filepath.Join(root, "dingtalk-workspace-cli.rb")
|
||||
mustWriteFile(t, sourceFormula, []byte(initialFormula), 0o644)
|
||||
publishOutput := filepath.Join(root, "publish-output")
|
||||
|
||||
beforeHead := strings.TrimSpace(mustOutput(t, root, "git", "ls-remote", remoteDir, "refs/heads/main"))
|
||||
|
||||
@@ -124,7 +76,6 @@ func TestPublishHomebrewFormulaSkipsWhenFormulaUnchanged(t *testing.T) {
|
||||
"DWS_TAP_REPO_URL="+remoteDir,
|
||||
"DWS_TAP_BRANCH=main",
|
||||
"DWS_FORMULA_SOURCE="+sourceFormula,
|
||||
"DWS_PUBLISH_OUTPUT="+publishOutput,
|
||||
"DWS_GIT_NAME=DWS Bot",
|
||||
"DWS_GIT_EMAIL=dws@example.com",
|
||||
)
|
||||
@@ -140,125 +91,6 @@ func TestPublishHomebrewFormulaSkipsWhenFormulaUnchanged(t *testing.T) {
|
||||
if beforeHead != afterHead {
|
||||
t.Fatalf("remote head changed unexpectedly:\nbefore: %s\nafter: %s", beforeHead, afterHead)
|
||||
}
|
||||
result, err := os.ReadFile(publishOutput)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(publish output) error = %v", err)
|
||||
}
|
||||
wantResult := "formula_changed=false\npublished_commit=" + formulaCommit + "\n"
|
||||
if string(result) != wantResult {
|
||||
t.Fatalf("no-op publish result = %q, want last matching Formula-only bot commit %q", result, wantResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishHomebrewFormulaRetriesNonFastForwardFromFreshMain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "publish-homebrew-formula.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(publish-homebrew-formula.sh) error = %v", err)
|
||||
}
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Fatalf("LookPath(git) error = %v", err)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
remoteDir := filepath.Join(root, "tap.git")
|
||||
mustRun(t, root, "git", "init", "--bare", remoteDir)
|
||||
seedTapRepo(t, remoteDir, "main", "class OldFormula < Formula\nend\n")
|
||||
|
||||
raceDir := filepath.Join(root, "racer")
|
||||
mustRun(t, root, "git", "clone", "--branch", "main", remoteDir, raceDir)
|
||||
mustRun(t, raceDir, "git", "config", "user.name", "Concurrent Maintainer")
|
||||
mustRun(t, raceDir, "git", "config", "user.email", "concurrent@example.com")
|
||||
mustWriteFile(t, filepath.Join(raceDir, "README.md"), []byte("concurrent main update\n"), 0o644)
|
||||
mustRun(t, raceDir, "git", "add", "README.md")
|
||||
mustRun(t, raceDir, "git", "commit", "-m", "docs: concurrent main update")
|
||||
raceCommit := strings.TrimSpace(mustOutput(t, raceDir, "git", "rev-parse", "HEAD"))
|
||||
|
||||
sourceFormula := filepath.Join(root, "dingtalk-workspace-cli.rb")
|
||||
newFormula := "class DingtalkWorkspaceCli < Formula\n desc \"DingTalk Workspace CLI\"\nend\n"
|
||||
mustWriteFile(t, sourceFormula, []byte(newFormula), 0o644)
|
||||
publishOutput := filepath.Join(root, "publish-output")
|
||||
|
||||
fakeBin := filepath.Join(root, "bin")
|
||||
gitLog := filepath.Join(root, "git.log")
|
||||
raceOnce := filepath.Join(root, "race-once")
|
||||
mustWriteFile(t, filepath.Join(fakeBin, "git"), []byte(`#!/bin/sh
|
||||
set -eu
|
||||
printf '%s\n' "$*" >> "$GIT_LOG"
|
||||
if [ "${1:-}" = push ] && [ "${2:-}" = origin ] &&
|
||||
[ "${3:-}" = "HEAD:main" ] && [ ! -e "$GIT_RACE_ONCE" ]; then
|
||||
: > "$GIT_RACE_ONCE"
|
||||
"$REAL_GIT" -C "$RACE_REPO" push origin main >/dev/null
|
||||
exit 1
|
||||
fi
|
||||
exec "$REAL_GIT" "$@"
|
||||
`), 0o755)
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||
"REAL_GIT="+realGit,
|
||||
"RACE_REPO="+raceDir,
|
||||
"GIT_LOG="+gitLog,
|
||||
"GIT_RACE_ONCE="+raceOnce,
|
||||
"DWS_TAP_REPO_URL="+remoteDir,
|
||||
"DWS_TAP_BRANCH=main",
|
||||
"DWS_FORMULA_SOURCE="+sourceFormula,
|
||||
"DWS_PUBLISH_OUTPUT="+publishOutput,
|
||||
"DWS_GIT_NAME=DWS Bot",
|
||||
"DWS_GIT_EMAIL=dws@example.com",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("publish-homebrew-formula.sh race recovery error = %v\noutput:\n%s", err, string(output))
|
||||
}
|
||||
if !strings.Contains(string(output), "retrying from a fresh clone") ||
|
||||
!strings.Contains(string(output), "Published Homebrew formula") {
|
||||
t.Fatalf("publisher did not report bounded fresh-clone recovery:\n%s", output)
|
||||
}
|
||||
|
||||
checkDir := filepath.Join(root, "check")
|
||||
mustRun(t, root, "git", "clone", "--branch", "main", remoteDir, checkDir)
|
||||
publishedCommit := strings.TrimSpace(mustOutput(t, checkDir, "git", "rev-parse", "HEAD"))
|
||||
parent := strings.TrimSpace(mustOutput(t, checkDir, "git", "rev-parse", "HEAD^"))
|
||||
if parent != raceCommit {
|
||||
t.Fatalf("retried Formula commit parent = %s, want concurrent main %s", parent, raceCommit)
|
||||
}
|
||||
parentLine := strings.Fields(mustOutput(t, checkDir, "git", "rev-list", "--parents", "-n", "1", "HEAD"))
|
||||
if len(parentLine) != 2 {
|
||||
t.Fatalf("retried Formula commit parent fields = %v, want exactly one parent", parentLine)
|
||||
}
|
||||
changedPaths := strings.TrimSpace(mustOutput(t, checkDir, "git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"))
|
||||
if changedPaths != "Formula/dingtalk-workspace-cli.rb" {
|
||||
t.Fatalf("retried Formula commit changed %q", changedPaths)
|
||||
}
|
||||
formula, err := os.ReadFile(filepath.Join(checkDir, "Formula", "dingtalk-workspace-cli.rb"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(published formula) error = %v", err)
|
||||
}
|
||||
if string(formula) != newFormula {
|
||||
t.Fatalf("published formula = %q, want %q", formula, newFormula)
|
||||
}
|
||||
result, err := os.ReadFile(publishOutput)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(publish output) error = %v", err)
|
||||
}
|
||||
wantResult := "formula_changed=true\npublished_commit=" + publishedCommit + "\n"
|
||||
if string(result) != wantResult {
|
||||
t.Fatalf("race-recovered publish result = %q, want %q", result, wantResult)
|
||||
}
|
||||
gitCalls, err := os.ReadFile(gitLog)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(git log) error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(gitCalls), "--force") {
|
||||
t.Fatalf("direct Formula publication must never force push:\n%s", gitCalls)
|
||||
}
|
||||
if got := strings.Count(string(gitCalls), "push origin HEAD:main"); got != 2 {
|
||||
t.Fatalf("direct Formula push attempts = %d, want one rejected attempt and one retry\n%s", got, gitCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishHomebrewFormulaOpensPRWithoutWritingMain(t *testing.T) {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type reviewerRouterWorkflow struct {
|
||||
On map[string]reviewerRouterTrigger `yaml:"on"`
|
||||
Permissions map[string]string `yaml:"permissions"`
|
||||
Jobs map[string]reviewerRouterJob `yaml:"jobs"`
|
||||
}
|
||||
|
||||
type reviewerRouterTrigger struct {
|
||||
Branches []string `yaml:"branches"`
|
||||
Types []string `yaml:"types"`
|
||||
}
|
||||
|
||||
type reviewerRouterJob struct {
|
||||
If string `yaml:"if"`
|
||||
Steps []reviewerRouterStep `yaml:"steps"`
|
||||
}
|
||||
|
||||
type reviewerRouterStep struct {
|
||||
Uses string `yaml:"uses"`
|
||||
With map[string]string `yaml:"with"`
|
||||
}
|
||||
|
||||
func TestReviewerRouterWorkflowContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "reviewer-router.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(reviewer-router.yml) error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", path, err)
|
||||
}
|
||||
|
||||
var workflow reviewerRouterWorkflow
|
||||
if err := yaml.Unmarshal(data, &workflow); err != nil {
|
||||
t.Fatalf("yaml.Unmarshal(%s) error = %v", path, err)
|
||||
}
|
||||
|
||||
if len(workflow.On) != 1 {
|
||||
t.Fatalf("workflow triggers = %v, want pull_request_target only", workflow.On)
|
||||
}
|
||||
trigger, ok := workflow.On["pull_request_target"]
|
||||
if !ok {
|
||||
t.Fatalf("workflow triggers = %v, want pull_request_target", workflow.On)
|
||||
}
|
||||
if wantBranches := []string{"main"}; !reflect.DeepEqual(trigger.Branches, wantBranches) {
|
||||
t.Fatalf("pull_request_target branches = %v, want %v", trigger.Branches, wantBranches)
|
||||
}
|
||||
wantTypes := []string{"opened", "synchronize", "reopened", "ready_for_review"}
|
||||
if !reflect.DeepEqual(trigger.Types, wantTypes) {
|
||||
t.Fatalf("pull_request_target types = %v, want %v", trigger.Types, wantTypes)
|
||||
}
|
||||
|
||||
wantPermissions := map[string]string{
|
||||
"contents": "write",
|
||||
"pull-requests": "write",
|
||||
}
|
||||
if !reflect.DeepEqual(workflow.Permissions, wantPermissions) {
|
||||
t.Fatalf("workflow permissions = %v, want exactly %v", workflow.Permissions, wantPermissions)
|
||||
}
|
||||
|
||||
if len(workflow.Jobs) != 1 {
|
||||
t.Fatalf("workflow jobs = %v, want one isolated routing job", workflow.Jobs)
|
||||
}
|
||||
job, ok := workflow.Jobs["route"]
|
||||
if !ok {
|
||||
t.Fatalf("workflow jobs = %v, want route", workflow.Jobs)
|
||||
}
|
||||
if job.If != "github.event.pull_request.draft == false" {
|
||||
t.Fatalf("route.if = %q, want non-draft guard", job.If)
|
||||
}
|
||||
const githubScriptSHA = "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b"
|
||||
if len(job.Steps) != 1 || job.Steps[0].Uses != githubScriptSHA {
|
||||
t.Fatalf("route steps = %#v, want one pinned base-owned github-script step", job.Steps)
|
||||
}
|
||||
|
||||
script := job.Steps[0].With["script"]
|
||||
for _, want := range []string{
|
||||
"'sczheng189'",
|
||||
"'shangguanxuan633-lab'",
|
||||
"'audanye-sudo'",
|
||||
"'wxianfeng'",
|
||||
"github.rest.pulls.get",
|
||||
"currentPull.head.sha !== eventHeadSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"getReadyEventPull('review request')",
|
||||
"getReadyEventPull('auto-merge enable')",
|
||||
"context.payload.action === 'synchronize'",
|
||||
"context.payload.sender?.login?.toLowerCase()",
|
||||
"reviewer.toLowerCase() !== author",
|
||||
"reviewer.toLowerCase() !== latestPusher",
|
||||
"pullRequest.requested_reviewers",
|
||||
"pullRequest.requested_teams",
|
||||
"github.rest.pulls.listReviews",
|
||||
"['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes",
|
||||
"review.commit_id === headSha",
|
||||
"['APPROVED', 'CHANGES_REQUESTED'].includes(review.state)",
|
||||
"review.state === 'CHANGES_REQUESTED'",
|
||||
"staleChangeRequester",
|
||||
"state: 'open'",
|
||||
"loads.set(candidate, loads.get(candidate) + 1)",
|
||||
"loads.get(left) - loads.get(right)",
|
||||
"for (const reviewer of ranked)",
|
||||
"github.rest.pulls.requestReviewers",
|
||||
"trying the next candidate",
|
||||
"Reviewer routing hit an unexpected error",
|
||||
"enablePullRequestAutoMerge",
|
||||
"mergeMethod: MERGE",
|
||||
"core.warning",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Errorf("reviewer router script is missing contract marker %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, forbidden := range []string{
|
||||
"actions/checkout",
|
||||
"['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED']",
|
||||
"PeterGuy326",
|
||||
"core.setFailed",
|
||||
} {
|
||||
if strings.Contains(string(data), forbidden) {
|
||||
t.Errorf("reviewer router must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestShortcutRealResultSelfTest wires scripts/shortcut_real_result_test.py into
|
||||
// `go test ./...` so the upper/lower projection-audit rules (and the end-to-end
|
||||
// record_real_shortcut_run.py integration) are enforced in CI, not manual-only.
|
||||
func TestShortcutRealResultSelfTest(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("repo root: %v", err)
|
||||
}
|
||||
py, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
t.Skipf("python3 not available: %v", err)
|
||||
}
|
||||
cmd := exec.Command(py, filepath.Join("scripts", "shortcut_real_result_test.py"))
|
||||
cmd.Dir = root
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("shortcut_real_result_test.py failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user