mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda2b9cd85 | ||
|
|
a703202ef8 | ||
|
|
eb8b542f42 | ||
|
|
d4c051d211 | ||
|
|
5621d2e555 | ||
|
|
17698d5c6a | ||
|
|
70c72a2c02 | ||
|
|
d4e83df22c | ||
|
|
4c51a9874d | ||
|
|
6463ab13c9 | ||
|
|
c4851a5c45 |
16
.github/pull_request_template.md
vendored
Normal file
16
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
## Summary
|
||||
<!-- Briefly describe the motivation and scope of this change in 1-3 sentences. -->
|
||||
|
||||
## Changes
|
||||
<!-- List the main changes in this PR. -->
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
## Test Plan
|
||||
<!-- Describe how this change was verified. -->
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Manual local verification confirms the `lark xxx` command works as expected
|
||||
|
||||
## Related Issues
|
||||
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
||||
- None
|
||||
149
.github/workflows/pkg-pr-new-comment.yml
vendored
Normal file
149
.github/workflows/pkg-pr-new-comment.yml
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
name: PR Preview Package Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR Preview Package"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check comment payload artifact
|
||||
id: payload
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const runId = context.payload.workflow_run?.id;
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId,
|
||||
per_page: 100,
|
||||
});
|
||||
const found = Boolean(
|
||||
data.artifacts?.some((artifact) => artifact.name === "pkg-pr-new-comment-payload")
|
||||
);
|
||||
core.setOutput("found", found ? "true" : "false");
|
||||
if (!found) {
|
||||
core.notice("No comment payload artifact found for this run; skipping comment.");
|
||||
}
|
||||
|
||||
- name: Download comment payload
|
||||
if: steps.payload.outputs.found == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: pkg-pr-new-comment-payload
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Comment install command
|
||||
if: steps.payload.outputs.found == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const payload = JSON.parse(fs.readFileSync("pkg-pr-new-comment-payload.json", "utf8"));
|
||||
const url = payload?.url;
|
||||
const payloadPr = payload?.pr;
|
||||
const sourceRepo = payload?.sourceRepo;
|
||||
const sourceBranch = payload?.sourceBranch;
|
||||
if (!Number.isInteger(payloadPr)) {
|
||||
throw new Error(`Invalid PR number in artifact payload: ${payloadPr}`);
|
||||
}
|
||||
if (payloadPr <= 0) {
|
||||
throw new Error(`Invalid PR number in artifact payload: ${payloadPr}`);
|
||||
}
|
||||
const issueNumber = payloadPr;
|
||||
const runPrNumber = context.payload.workflow_run?.pull_requests?.[0]?.number;
|
||||
if (Number.isInteger(runPrNumber) && runPrNumber !== issueNumber) {
|
||||
throw new Error(
|
||||
`PR number mismatch between workflow_run (${runPrNumber}) and artifact payload (${issueNumber})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof url !== "string" || url.trim() !== url || /[\u0000-\u001F\u007F]/.test(url)) {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
if (parsedUrl.protocol !== "https:" || parsedUrl.hostname !== "pkg.pr.new") {
|
||||
throw new Error(`Invalid package URL in payload: ${url}`);
|
||||
}
|
||||
|
||||
const safeRepoPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const safeBranchPattern = /^[A-Za-z0-9._\/-]+$/;
|
||||
const hasSkillSource =
|
||||
typeof sourceRepo === "string" &&
|
||||
typeof sourceBranch === "string" &&
|
||||
safeRepoPattern.test(sourceRepo) &&
|
||||
safeBranchPattern.test(sourceBranch);
|
||||
const skillSection = hasSkillSource
|
||||
? [
|
||||
"",
|
||||
"### 🧩 Skill update",
|
||||
"",
|
||||
"```bash",
|
||||
`npx skills add ${sourceRepo}#${sourceBranch} -y -g`,
|
||||
"```",
|
||||
]
|
||||
: [
|
||||
"",
|
||||
"### 🧩 Skill update",
|
||||
"",
|
||||
"_Unavailable for this PR because source repo/branch metadata is missing._",
|
||||
];
|
||||
|
||||
const body = [
|
||||
"<!-- pkg-pr-new-install-guide -->",
|
||||
"## 🚀 PR Preview Install Guide",
|
||||
"",
|
||||
"### 🧰 CLI update",
|
||||
"",
|
||||
"```bash",
|
||||
`npm i -g ${url}`,
|
||||
"```",
|
||||
...skillSection,
|
||||
].join("\n");
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find((comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
typeof comment.body === "string" &&
|
||||
comment.body.includes("<!-- pkg-pr-new-install-guide -->")
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
86
.github/workflows/pkg-pr-new.yml
vendored
86
.github/workflows/pkg-pr-new.yml
vendored
@@ -7,7 +7,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -31,51 +30,42 @@ jobs:
|
||||
- name: Publish to pkg.pr.new
|
||||
run: npx pkg-pr-new publish --no-compact --json output.json --comment=off ./.pkg-pr-new
|
||||
|
||||
- name: Comment install command
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
- name: Build comment payload
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
SOURCE_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
SOURCE_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("fs");
|
||||
|
||||
const output = JSON.parse(fs.readFileSync("output.json", "utf8"));
|
||||
const url = output?.packages?.[0]?.url;
|
||||
if (!url) throw new Error("No package URL found in output.json");
|
||||
if (!url.startsWith("https://pkg.pr.new/")) {
|
||||
throw new Error(`Unexpected package URL: ${url}`);
|
||||
}
|
||||
|
||||
const pr = Number(process.env.PR_NUMBER);
|
||||
if (!Number.isInteger(pr) || pr <= 0) {
|
||||
throw new Error(`Invalid PR_NUMBER: ${process.env.PR_NUMBER}`);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
pr,
|
||||
url,
|
||||
sourceRepo: process.env.SOURCE_REPO || "",
|
||||
sourceBranch: process.env.SOURCE_BRANCH || "",
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
"pkg-pr-new-comment-payload.json",
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
NODE
|
||||
|
||||
- name: Upload comment payload
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const output = JSON.parse(fs.readFileSync("output.json", "utf8"));
|
||||
const url = output?.packages?.[0]?.url;
|
||||
if (!url) {
|
||||
throw new Error("No package URL found in output.json");
|
||||
}
|
||||
|
||||
const body = [
|
||||
"Install this PR change globally:",
|
||||
"",
|
||||
"```bash",
|
||||
`npm i -g ${url}`,
|
||||
"```",
|
||||
].join("\n");
|
||||
const issueNumber = context.issue.number;
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find((comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
typeof comment.body === "string" &&
|
||||
comment.body.startsWith("Install this PR change globally:")
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
name: pkg-pr-new-comment-payload
|
||||
path: pkg-pr-new-comment-payload.json
|
||||
|
||||
31
.github/workflows/pr-labels-test.yml
vendored
Normal file
31
.github/workflows/pr-labels-test.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Test PR Label Logic
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/pr-labels/**"
|
||||
- ".github/workflows/pr-labels-test.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/pr-labels/**"
|
||||
- ".github/workflows/pr-labels-test.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-pr-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Run PR label regression tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/pr-labels/test.js
|
||||
43
.github/workflows/pr-labels.yml
vendored
Normal file
43
.github/workflows/pr-labels.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: PR Labels
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# NOTE: This event runs with base-branch code and write permissions.
|
||||
# Do NOT add `ref: github.event.pull_request.head.sha` to the checkout step,
|
||||
# as that would execute untrusted PR code with elevated access.
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
sync-pr-labels:
|
||||
if: ${{ github.event.pull_request.state == 'open' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Sync managed PR labels
|
||||
id: sync_pr_labels
|
||||
# Labeling is best-effort and must not block PR merges.
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node scripts/pr-labels/index.js
|
||||
|
||||
- name: Warn when label sync fails
|
||||
if: ${{ always() && steps.sync_pr_labels.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::warning::PR label sync failed; labels may be stale."
|
||||
echo "⚠️ PR label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"
|
||||
32
.github/workflows/skill-format-check.yml
vendored
Normal file
32
.github/workflows/skill-format-check.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Skill Format Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- "scripts/skill-format-check/**"
|
||||
- ".github/workflows/skill-format-check.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- "scripts/skill-format-check/**"
|
||||
- ".github/workflows/skill-format-check.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Run Skill Format Check
|
||||
run: node scripts/skill-format-check/index.js
|
||||
33
AGENTS.md
Normal file
33
AGENTS.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# AGENTS.md
|
||||
Concise maintainer/developer guide for building, testing, and opening high-quality PRs in this repo.
|
||||
|
||||
## Goal (pick one per PR)
|
||||
- Make CLI better: improve UX, error messages, help text, flags, and output clarity.
|
||||
- Improve reliability: fix bugs, edge cases, and regressions with tests.
|
||||
- Improve developer velocity: simplify code paths, reduce complexity, keep behavior explicit.
|
||||
- Improve quality gates: strengthen tests/lint/checks without adding heavy process.
|
||||
|
||||
## Fast Dev Loop
|
||||
1. `make build` (runs `python3 scripts/fetch_meta.py` first)
|
||||
2. `make unit-test` (required before PR)
|
||||
3. Run changed command(s) manually via `./lark-cli ...`
|
||||
|
||||
## Pre-PR Checks (match CI gates)
|
||||
1. `make unit-test`
|
||||
2. `go mod tidy` (must not change `go.mod`/`go.sum`)
|
||||
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
|
||||
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
|
||||
5. Optional full local suite: `make test` (vet + unit + integration)
|
||||
|
||||
## Test/Check Commands
|
||||
- Unit: `make unit-test`
|
||||
- Integration: `make integration-test`
|
||||
- Full: `make test`
|
||||
- Vet only: `make vet`
|
||||
- Coverage (local): `go test -race -coverprofile=coverage.txt -covermode=atomic ./...`
|
||||
|
||||
## Commit/PR Rules
|
||||
- Use Conventional Commits in English: `feat: ...`, `fix: ...`, `docs: ...`, `ci: ...`, `test: ...`, `chore: ...`, `refactor: ...`
|
||||
- Keep PR title in the same Conventional Commit format (squash merge keeps it).
|
||||
- Before opening a real PR, draft/fill description from `.github/pull_request_template.md` and ensure Summary/Changes/Test Plan are complete.
|
||||
- Never commit secrets/tokens/internal sensitive data.
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,29 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.2] - 2026-04-01
|
||||
|
||||
### Features
|
||||
|
||||
- Improve OS keychain/DPAPI access error handling for sandbox environments (#173)
|
||||
- **mail**: Auto-resolve local image paths in draft body HTML (#139)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct URL formatting in login `--no-wait` output (#169)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add concise AGENTS development guide (#178)
|
||||
|
||||
### CI
|
||||
|
||||
- Refine PR business area labels and introduce skill format check (#148)
|
||||
|
||||
### Chore
|
||||
|
||||
- Add pull request template (#176)
|
||||
|
||||
## [v1.0.1] - 2026-03-31
|
||||
|
||||
### Features
|
||||
@@ -87,5 +110,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.2]: https://github.com/larksuite/cli/releases/tag/v1.0.2
|
||||
[v1.0.1]: https://github.com/larksuite/cli/releases/tag/v1.0.1
|
||||
[v1.0.0]: https://github.com/larksuite/cli/releases/tag/v1.0.0
|
||||
|
||||
@@ -90,6 +90,7 @@ func completeDomain(toComplete string) []string {
|
||||
return completions
|
||||
}
|
||||
|
||||
// authLoginRun executes the login command logic.
|
||||
func authLoginRun(opts *LoginOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
@@ -225,26 +226,34 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// --no-wait: return immediately with device code and URL
|
||||
if opts.NoWait {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
data := map[string]interface{}{
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
"verification_uri": authResp.VerificationUri,
|
||||
"verification_uri_complete": authResp.VerificationUriComplete,
|
||||
"user_code": authResp.UserCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
|
||||
11
cmd/root.go
11
cmd/root.go
@@ -4,6 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -241,12 +242,18 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
}
|
||||
|
||||
env := map[string]interface{}{"ok": false, "error": errData}
|
||||
b, err := json.MarshalIndent(env, "", " ")
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
encoder := json.NewEncoder(buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
err := encoder.Encode(env)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
fmt.Fprint(w, buffer.String())
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
|
||||
@@ -39,8 +39,8 @@ func MaskToken(token string) string {
|
||||
|
||||
// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair.
|
||||
func GetStoredToken(appId, userOpenId string) *StoredUAToken {
|
||||
jsonStr := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
|
||||
if jsonStr == "" {
|
||||
jsonStr, err := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
|
||||
if err != nil || jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
var token StoredUAToken
|
||||
|
||||
@@ -5,11 +5,13 @@ package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -113,6 +115,12 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
app := raw.Apps[0]
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
// If the error comes from the keychain, it will already be wrapped as an ExitError.
|
||||
// For other errors (e.g. file read errors, unknown sources), wrap them as ConfigError.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, exitErr
|
||||
}
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()}
|
||||
}
|
||||
cfg := &CliConfig{
|
||||
|
||||
@@ -3,17 +3,12 @@
|
||||
|
||||
package keychain
|
||||
|
||||
import "fmt"
|
||||
|
||||
// defaultKeychain implements KeychainAccess using the real platform keychain.
|
||||
// defaultKeychain is the default implementation of KeychainAccess
|
||||
// that uses the package-level functions.
|
||||
type defaultKeychain struct{}
|
||||
|
||||
func (d *defaultKeychain) Get(service, account string) (string, error) {
|
||||
val := Get(service, account)
|
||||
if val == "" {
|
||||
return "", fmt.Errorf("keychain entry not found: %s/%s", service, account)
|
||||
}
|
||||
return val, nil
|
||||
return Get(service, account)
|
||||
}
|
||||
|
||||
func (d *defaultKeychain) Set(service, account, value string) error {
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
// macOS uses the system Keychain; Linux uses AES-256-GCM encrypted files; Windows uses DPAPI + registry.
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
var errNotInitialized = errors.New("keychain not initialized")
|
||||
|
||||
const (
|
||||
// LarkCliService is the unified keychain service name for all secrets
|
||||
// (both AppSecret and UAT). Entries are distinguished by account key format:
|
||||
@@ -13,6 +22,22 @@ const (
|
||||
LarkCliService = "lark-cli"
|
||||
)
|
||||
|
||||
// wrapError is a helper to wrap underlying errors into output.ExitError.
|
||||
// It formats the error message and provides a hint for troubleshooting keychain access issues.
|
||||
func wrapError(op string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := fmt.Sprintf("keychain %s failed: %v", op, err)
|
||||
hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain."
|
||||
|
||||
if errors.Is(err, errNotInitialized) {
|
||||
hint = "The keychain master key may have been cleaned up or deleted. Please reconfigure the CLI by running `lark-cli config init`."
|
||||
}
|
||||
|
||||
return output.ErrWithHint(output.ExitAPI, "config", msg, hint)
|
||||
}
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
// Used by AppSecret operations (ForStorage, ResolveSecretInput, RemoveSecretStore).
|
||||
// UAT operations in token_store.go use the package-level Get/Set/Remove directly.
|
||||
@@ -24,16 +49,17 @@ type KeychainAccess interface {
|
||||
|
||||
// Get retrieves a value from the keychain.
|
||||
// Returns empty string if the entry does not exist.
|
||||
func Get(service, account string) string {
|
||||
return platformGet(service, account)
|
||||
func Get(service, account string) (string, error) {
|
||||
val, err := platformGet(service, account)
|
||||
return val, wrapError("Get", err)
|
||||
}
|
||||
|
||||
// Set stores a value in the keychain, overwriting any existing entry.
|
||||
func Set(service, account, data string) error {
|
||||
return platformSet(service, account, data)
|
||||
return wrapError("Set", platformSet(service, account, data))
|
||||
}
|
||||
|
||||
// Remove deletes an entry from the keychain. No error if not found.
|
||||
func Remove(service, account string) error {
|
||||
return platformRemove(service, account)
|
||||
return wrapError("Remove", platformRemove(service, account))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -36,11 +37,14 @@ func StorageDir(service string) string {
|
||||
|
||||
var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// safeFileName sanitizes an account name to be used as a safe file name.
|
||||
func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
func getMasterKey(service string) ([]byte, error) {
|
||||
// getMasterKey retrieves the master key from the system keychain.
|
||||
// If allowCreate is true, it generates and stores a new master key if one doesn't exist.
|
||||
func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -59,28 +63,48 @@ func getMasterKey(service string) ([]byte, error) {
|
||||
resCh <- result{key: key, err: nil}
|
||||
return
|
||||
}
|
||||
// Key is found but invalid or corrupted
|
||||
resCh <- result{key: nil, err: errors.New("keychain is corrupted")}
|
||||
return
|
||||
} else if !errors.Is(err, keyring.ErrNotFound) {
|
||||
// Not ErrNotFound, which means access was denied or blocked by the system
|
||||
resCh <- result{key: nil, err: errors.New("keychain access blocked")}
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new master key if not found or invalid
|
||||
// If ErrNotFound, check if we are allowed to create a new key
|
||||
if !allowCreate {
|
||||
// Creation not allowed (e.g., during Get operation), return error
|
||||
resCh <- result{key: nil, err: errNotInitialized}
|
||||
return
|
||||
}
|
||||
|
||||
// It's the first time and creation is allowed (Set operation), generate a new key
|
||||
key := make([]byte, masterKeyBytes)
|
||||
if _, randErr := rand.Read(key); randErr != nil {
|
||||
resCh <- result{key: nil, err: randErr}
|
||||
return
|
||||
}
|
||||
|
||||
encodedKey = base64.StdEncoding.EncodeToString(key)
|
||||
setErr := keyring.Set(service, "master.key", encodedKey)
|
||||
resCh <- result{key: key, err: setErr}
|
||||
encodedKeyStr := base64.StdEncoding.EncodeToString(key)
|
||||
setErr := keyring.Set(service, "master.key", encodedKeyStr)
|
||||
if setErr != nil {
|
||||
resCh <- result{key: nil, err: setErr}
|
||||
return
|
||||
}
|
||||
resCh <- result{key: key, err: nil}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resCh:
|
||||
return res.key, res.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
// Timeout is usually caused by ignored/blocked permission prompts
|
||||
return nil, errors.New("keychain access blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// encryptData encrypts data using AES-GCM.
|
||||
func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
@@ -103,6 +127,7 @@ func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decryptData decrypts data using AES-GCM.
|
||||
func decryptData(data []byte, key []byte) (string, error) {
|
||||
if len(data) < ivBytes+tagBytes {
|
||||
return "", os.ErrInvalid
|
||||
@@ -125,24 +150,30 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func platformGet(service, account string) string {
|
||||
key, err := getMasterKey(service)
|
||||
if err != nil {
|
||||
return ""
|
||||
// platformGet retrieves a value from the macOS keychain.
|
||||
func platformGet(service, account string) (string, error) {
|
||||
path := filepath.Join(StorageDir(service), safeFileName(account))
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
key, err := getMasterKey(service, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
return plaintext
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// platformSet stores a value in the macOS keychain.
|
||||
func platformSet(service, account, data string) error {
|
||||
key, err := getMasterKey(service)
|
||||
key, err := getMasterKey(service, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,6 +201,7 @@ func platformSet(service, account, data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// platformRemove deletes a value from the macOS keychain.
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -21,8 +22,7 @@ const masterKeyBytes = 32
|
||||
const ivBytes = 12
|
||||
const tagBytes = 16
|
||||
|
||||
// StorageDir returns the storage directory for a given service name.
|
||||
// Each service gets its own directory for physical isolation.
|
||||
// StorageDir returns the directory where encrypted files are stored.
|
||||
func StorageDir(service string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
@@ -36,11 +36,14 @@ func StorageDir(service string) string {
|
||||
|
||||
var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// safeFileName sanitizes an account name to be used as a safe file name.
|
||||
func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
func getMasterKey(service string) ([]byte, error) {
|
||||
// getMasterKey retrieves the master key from the file system.
|
||||
// If allowCreate is true, it generates and stores a new master key if one doesn't exist.
|
||||
func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, "master.key")
|
||||
|
||||
@@ -48,6 +51,18 @@ func getMasterKey(service string) ([]byte, error) {
|
||||
if err == nil && len(key) == masterKeyBytes {
|
||||
return key, nil
|
||||
}
|
||||
if err == nil && len(key) != masterKeyBytes {
|
||||
// Key file exists but is corrupted
|
||||
return nil, errors.New("keychain is corrupted")
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
// Real I/O error (permission denied, etc.) - propagate it
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !allowCreate {
|
||||
return nil, errNotInitialized
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
@@ -78,6 +93,7 @@ func getMasterKey(service string) ([]byte, error) {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// encryptData encrypts data using AES-GCM.
|
||||
func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
@@ -100,6 +116,7 @@ func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decryptData decrypts data using AES-GCM.
|
||||
func decryptData(data []byte, key []byte) (string, error) {
|
||||
if len(data) < ivBytes+tagBytes {
|
||||
return "", os.ErrInvalid
|
||||
@@ -122,24 +139,30 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func platformGet(service, account string) string {
|
||||
key, err := getMasterKey(service)
|
||||
if err != nil {
|
||||
return ""
|
||||
// platformGet retrieves a value from the file system.
|
||||
func platformGet(service, account string) (string, error) {
|
||||
path := filepath.Join(StorageDir(service), safeFileName(account))
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
key, err := getMasterKey(service, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
return plaintext
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// platformSet stores a value in the file system.
|
||||
func platformSet(service, account, data string) error {
|
||||
key, err := getMasterKey(service)
|
||||
key, err := getMasterKey(service, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -167,6 +190,7 @@ func platformSet(service, account, data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// platformRemove deletes a value from the file system.
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
|
||||
@@ -22,12 +22,14 @@ import (
|
||||
|
||||
const regRootPath = `Software\LarkCli\keychain`
|
||||
|
||||
// registryPathForService returns the registry path for a given service.
|
||||
func registryPathForService(service string) string {
|
||||
return regRootPath + `\` + safeRegistryComponent(service)
|
||||
}
|
||||
|
||||
var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// safeRegistryComponent sanitizes a string to be used as a registry key component.
|
||||
func safeRegistryComponent(s string) string {
|
||||
// Registry key path uses '\\' separators; avoid accidental nesting and odd chars.
|
||||
s = strings.ReplaceAll(s, "\\", "_")
|
||||
@@ -39,6 +41,7 @@ func valueNameForAccount(account string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(account))
|
||||
}
|
||||
|
||||
// dpapiEntropy generates entropy for DPAPI encryption based on the service and account names.
|
||||
func dpapiEntropy(service, account string) *windows.DataBlob {
|
||||
// Bind ciphertext to (service, account) to reduce swap/replay risks.
|
||||
// Note: empty entropy is allowed, but we intentionally use deterministic entropy.
|
||||
@@ -49,6 +52,7 @@ func dpapiEntropy(service, account string) *windows.DataBlob {
|
||||
return &windows.DataBlob{Size: uint32(len(data)), Data: &data[0]}
|
||||
}
|
||||
|
||||
// dpapiProtect encrypts data using Windows DPAPI.
|
||||
func dpapiProtect(plaintext []byte, entropy *windows.DataBlob) ([]byte, error) {
|
||||
var in windows.DataBlob
|
||||
if len(plaintext) > 0 {
|
||||
@@ -70,6 +74,7 @@ func dpapiProtect(plaintext []byte, entropy *windows.DataBlob) ([]byte, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// dpapiUnprotect decrypts data using Windows DPAPI.
|
||||
func dpapiUnprotect(ciphertext []byte, entropy *windows.DataBlob) ([]byte, error) {
|
||||
var in windows.DataBlob
|
||||
if len(ciphertext) > 0 {
|
||||
@@ -91,6 +96,7 @@ func dpapiUnprotect(ciphertext []byte, entropy *windows.DataBlob) ([]byte, error
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// freeDataBlob frees the memory allocated for a DataBlob.
|
||||
func freeDataBlob(b *windows.DataBlob) {
|
||||
if b == nil || b.Data == nil {
|
||||
return
|
||||
@@ -101,11 +107,16 @@ func freeDataBlob(b *windows.DataBlob) {
|
||||
b.Size = 0
|
||||
}
|
||||
|
||||
func platformGet(service, account string) string {
|
||||
v, _ := registryGet(service, account)
|
||||
return v
|
||||
// platformGet retrieves a value from the Windows registry.
|
||||
func platformGet(service, account string) (string, error) {
|
||||
v, ok := registryGet(service, account)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// platformSet stores a value in the Windows registry.
|
||||
func platformSet(service, account, data string) error {
|
||||
entropy := dpapiEntropy(service, account)
|
||||
protected, err := dpapiProtect([]byte(data), entropy)
|
||||
@@ -115,10 +126,12 @@ func platformSet(service, account, data string) error {
|
||||
return registrySet(service, account, protected)
|
||||
}
|
||||
|
||||
// platformRemove deletes a value from the Windows registry.
|
||||
func platformRemove(service, account string) error {
|
||||
return registryRemove(service, account)
|
||||
}
|
||||
|
||||
// registryGet retrieves a string value from the registry under the given service and account.
|
||||
func registryGet(service, account string) (string, bool) {
|
||||
keyPath := registryPathForService(service)
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE)
|
||||
@@ -143,6 +156,7 @@ func registryGet(service, account string) (string, bool) {
|
||||
return string(plain), true
|
||||
}
|
||||
|
||||
// registrySet stores a string value in the registry under the given service and account.
|
||||
func registrySet(service, account string, protected []byte) error {
|
||||
keyPath := registryPathForService(service)
|
||||
k, _, err := registry.CreateKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)
|
||||
@@ -158,6 +172,7 @@ func registrySet(service, account string, protected []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// registryRemove deletes a value from the registry under the given service and account.
|
||||
func registryRemove(service, account string) error {
|
||||
keyPath := registryPathForService(service)
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -67,8 +67,24 @@ def main():
|
||||
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
|
||||
parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"],
|
||||
help="API brand (default: feishu)")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="force refresh from remote even if local file exists")
|
||||
args = parser.parse_args()
|
||||
|
||||
if os.path.exists(OUT_PATH) and not args.force:
|
||||
if os.path.isfile(OUT_PATH):
|
||||
try:
|
||||
with open(OUT_PATH, "r", encoding="utf-8") as fp:
|
||||
local = json.load(fp)
|
||||
if local.get("services"):
|
||||
print(f"fetch-meta: {OUT_PATH} already exists, skipping (use --force to re-fetch)", file=sys.stderr)
|
||||
return
|
||||
print(f"fetch-meta: {OUT_PATH} has no services, re-fetching", file=sys.stderr)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
print(f"fetch-meta: {OUT_PATH} is invalid JSON, re-fetching", file=sys.stderr)
|
||||
else:
|
||||
print(f"fetch-meta: {OUT_PATH} is not a file, re-fetching", file=sys.stderr)
|
||||
|
||||
data = fetch_remote(args.brand)
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
|
||||
58
scripts/pr-labels/README.md
Normal file
58
scripts/pr-labels/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# PR Label Sync
|
||||
|
||||
This directory contains scripts and sample data for automatically classifying and labeling GitHub Pull Requests based on the files they modify.
|
||||
|
||||
## Files
|
||||
|
||||
- `index.js`: The main Node.js script. It fetches PR files, evaluates their risk level, calculates business impact, and uses GitHub APIs to add appropriate `size/*` and `domain/*` labels.
|
||||
- `samples.json`: A collection of historical PRs used as test cases to verify the labeling logic (especially for regression testing the S/M/L thresholds).
|
||||
|
||||
## Features
|
||||
|
||||
### Size Labels (`size/*`)
|
||||
The script evaluates the "effective" lines of code changed (ignoring tests, docs, and ci files) to classify the PR:
|
||||
- **`size/S`**: Low-risk changes involving only docs, tests, CI workflows, or chores.
|
||||
- **`size/M`**: Small-to-medium changes affecting a single business domain, with effective lines under 300.
|
||||
- **`size/L`**: Large features (>= 300 lines), cross-domain changes, or any changes touching core architecture paths (like `cmd/`).
|
||||
- **`size/XL`**: Architectural overhauls, extremely large PRs (>1200 lines), or sensitive refactors.
|
||||
|
||||
### Domain Tags (`domain/*`)
|
||||
The script also identifies which business domains a PR touches to give reviewers an immediate sense of the impact scope. Currently tracked domains include:
|
||||
- `domain/im`
|
||||
- `domain/vc`
|
||||
- `domain/ccm`
|
||||
- `domain/base`
|
||||
- `domain/mail`
|
||||
- `domain/calendar`
|
||||
- `domain/task`
|
||||
- `domain/contact`
|
||||
|
||||
Minor modules like docs and tests are omitted to keep PR tags clean and focused on structural changes.
|
||||
|
||||
## Usage
|
||||
|
||||
### In GitHub Actions
|
||||
This script is designed to run in CI workflows. It automatically reads the `GITHUB_EVENT_PATH` payload to get the PR context.
|
||||
|
||||
```bash
|
||||
node scripts/pr-labels/index.js
|
||||
```
|
||||
|
||||
### Local Dry Run
|
||||
You can test the labeling logic against an existing GitHub PR without actually applying labels by using the `--dry-run` flag.
|
||||
|
||||
```bash
|
||||
# Requires GITHUB_TOKEN environment variable or passing --token
|
||||
node scripts/pr-labels/index.js --dry-run --repo larksuite/cli --pr-number 123
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
A regression test suite is available in `test.js` which verifies the output of the classification logic against historical PRs configured in `samples.json`.
|
||||
|
||||
```bash
|
||||
# Requires GITHUB_TOKEN environment variable to avoid rate limits
|
||||
GITHUB_TOKEN=$(gh auth token) node scripts/pr-labels/test.js
|
||||
```
|
||||
|
||||
This test suite also runs automatically in CI via `.github/workflows/pr-labels-test.yml` when changes are made to this directory.
|
||||
747
scripts/pr-labels/index.js
Executable file
747
scripts/pr-labels/index.js
Executable file
@@ -0,0 +1,747 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Configuration
|
||||
// ============================================================================
|
||||
|
||||
const API_BASE = "https://api.github.com";
|
||||
const SCRIPT_DIR = __dirname;
|
||||
const ROOT = path.join(SCRIPT_DIR, "..", "..");
|
||||
|
||||
const THRESHOLD_L = 300;
|
||||
const THRESHOLD_XL = 1200;
|
||||
|
||||
const LABEL_DEFINITIONS = {
|
||||
"size/S": { color: "77bb00", description: "Low-risk docs, CI, test, or chore only changes" },
|
||||
"size/M": { color: "eebb00", description: "Single-domain feat or fix with limited business impact" },
|
||||
"size/L": { color: "ff8800", description: "Large or sensitive change across domains or core paths" },
|
||||
"size/XL": { color: "ee0000", description: "Architecture-level or global-impact change" },
|
||||
};
|
||||
|
||||
const MANAGED_LABELS = new Set(Object.keys(LABEL_DEFINITIONS));
|
||||
|
||||
// File path matching configurations
|
||||
const DOC_SUFFIXES = [".md", ".mdx", ".txt", ".rst"];
|
||||
const LOW_RISK_PREFIXES = [".github/", "docs/", ".changeset/", "testdata/", "tests/", "skill-template/"];
|
||||
const LOW_RISK_FILENAMES = new Set(["readme.md", "readme.zh.md", "changelog.md", "license", "cla.md"]);
|
||||
const LOW_RISK_TEST_SUFFIXES = ["_test.go", ".snap"];
|
||||
|
||||
const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/", "cmd/"];
|
||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
||||
|
||||
// CODEOWNERS-based path to domain label mapping
|
||||
// Maps shortcuts and skills paths to business domain labels
|
||||
const PATH_TO_DOMAIN_MAP = {
|
||||
// shortcuts
|
||||
"shortcuts/im/": "im",
|
||||
"shortcuts/vc/": "vc",
|
||||
"shortcuts/calendar/": "calendar",
|
||||
"shortcuts/doc/": "ccm",
|
||||
"shortcuts/sheets/": "ccm",
|
||||
"shortcuts/drive/": "ccm",
|
||||
"shortcuts/base/": "base",
|
||||
"shortcuts/mail/": "mail",
|
||||
"shortcuts/task/": "task",
|
||||
"shortcuts/contact/": "contact",
|
||||
// skills
|
||||
"skills/lark-im/": "im",
|
||||
"skills/lark-vc/": "vc",
|
||||
"skills/lark-doc/": "ccm",
|
||||
"skills/lark-base/": "base",
|
||||
"skills/lark-mail/": "mail",
|
||||
"skills/lark-calendar/": "calendar",
|
||||
"skills/lark-task/": "task",
|
||||
"skills/lark-contact/": "contact",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||
|
||||
const CLASS_STANDARDS = {
|
||||
"size/S": {
|
||||
channel: "Fast track (S)",
|
||||
gates: [
|
||||
"Code quality: AI code review passed",
|
||||
"Dependency and configuration security checks passed",
|
||||
],
|
||||
},
|
||||
"size/M": {
|
||||
channel: "Fast track (M)",
|
||||
gates: [
|
||||
"Code quality: AI code review passed",
|
||||
"Dependency and configuration security checks passed",
|
||||
"Skill format validation: added or modified Skills load successfully",
|
||||
"CLI automation tests: all required business-line tests passed",
|
||||
],
|
||||
},
|
||||
"size/L": {
|
||||
channel: "Standard track (L)",
|
||||
gates: [
|
||||
"Code quality: AI code review passed",
|
||||
"Dependency and configuration security checks passed",
|
||||
"Skill format validation: added or modified Skills load successfully",
|
||||
"CLI automation tests: all required business-line tests passed",
|
||||
"Domain evaluation passed: reported success rate is greater than 95%",
|
||||
],
|
||||
},
|
||||
"size/XL": {
|
||||
channel: "Strict track (XL)",
|
||||
gates: [
|
||||
"Code quality: AI code review passed",
|
||||
"Dependency and configuration security checks passed",
|
||||
"Skill format validation: added or modified Skills load successfully",
|
||||
"CLI automation tests: all required business-line tests passed",
|
||||
"Domain evaluation passed: reported success rate is greater than 95%",
|
||||
"Cross-domain release gate: all domains and full integration evaluations passed",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
function log(message) {
|
||||
console.error(`sync-pr-labels: ${message}`);
|
||||
}
|
||||
|
||||
function normalizePath(input) {
|
||||
return String(input || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function envValue(name) {
|
||||
return (process.env[name] || "").trim();
|
||||
}
|
||||
|
||||
function envOrFail(name) {
|
||||
const value = envValue(name);
|
||||
if (!value) {
|
||||
throw new Error(`missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GitHub API Client
|
||||
// ============================================================================
|
||||
|
||||
class GitHubClient {
|
||||
constructor(token, repo, prNumber) {
|
||||
this.token = token;
|
||||
this.repo = repo;
|
||||
this.prNumber = prNumber;
|
||||
}
|
||||
|
||||
buildHeaders(hasBody = false) {
|
||||
const headers = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
if (this.token) {
|
||||
headers.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
if (hasBody) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const { method = "GET", payload, allow404 = false } = options;
|
||||
const hasBody = payload !== undefined;
|
||||
const url = endpoint.startsWith("http") ? endpoint : `${API_BASE}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: this.buildHeaders(hasBody),
|
||||
body: hasBody ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
|
||||
if (allow404 && response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
const error = new Error(`GitHub API ${method} ${url} failed: ${response.status} ${detail}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async getPullRequest() {
|
||||
return this.request(`/repos/${this.repo}/pulls/${this.prNumber}`);
|
||||
}
|
||||
|
||||
async listPrFiles() {
|
||||
const files = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const params = new URLSearchParams({ per_page: "100", page: String(page) });
|
||||
const batch = await this.request(`/repos/${this.repo}/pulls/${this.prNumber}/files?${params}`);
|
||||
if (!batch || batch.length === 0) {
|
||||
break;
|
||||
}
|
||||
files.push(...batch);
|
||||
if (batch.length < 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async listIssueLabels() {
|
||||
const labels = await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels`);
|
||||
return new Set(labels.map((item) => item.name));
|
||||
}
|
||||
|
||||
async syncLabelDefinition(name) {
|
||||
const label = LABEL_DEFINITIONS[name];
|
||||
const createUrl = `/repos/${this.repo}/labels`;
|
||||
const updateUrl = `/repos/${this.repo}/labels/${encodeURIComponent(name)}`;
|
||||
|
||||
try {
|
||||
await this.request(createUrl, {
|
||||
method: "POST",
|
||||
payload: { name, color: label.color, description: label.description },
|
||||
});
|
||||
log(`created label ${name}`);
|
||||
} catch (error) {
|
||||
if (error.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
await this.request(updateUrl, {
|
||||
method: "PATCH",
|
||||
payload: { new_name: name, color: label.color, description: label.description },
|
||||
});
|
||||
log(`updated label ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async addLabels(labels) {
|
||||
if (labels.length === 0) return;
|
||||
await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels`, {
|
||||
method: "POST",
|
||||
payload: { labels },
|
||||
});
|
||||
log(`added labels: ${labels.join(", ")}`);
|
||||
}
|
||||
|
||||
async removeLabel(name) {
|
||||
await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
allow404: true,
|
||||
});
|
||||
log(`removed label: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path & Domain Heuristics
|
||||
// ============================================================================
|
||||
|
||||
function parsePrType(title) {
|
||||
const match = String(title || "").trim().match(/^([a-z]+)(?:\([^)]+\))?!?:/i);
|
||||
return match ? match[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
function isLowRiskPath(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
const basename = path.posix.basename(normalized);
|
||||
|
||||
if (normalized.startsWith("skills/lark-")) return false;
|
||||
if (DOC_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) return true;
|
||||
if (LOW_RISK_FILENAMES.has(basename)) return true;
|
||||
if (LOW_RISK_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return true;
|
||||
if (LOW_RISK_TEST_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) return true;
|
||||
return normalized.includes("/testdata/");
|
||||
}
|
||||
|
||||
function isBusinessSkillPath(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
return normalized.startsWith("shortcuts/") || normalized.startsWith("skills/lark-");
|
||||
}
|
||||
|
||||
function shortcutDomainForPath(filePath) {
|
||||
const parts = normalizePath(filePath).split("/");
|
||||
return parts.length >= 2 && parts[0] === "shortcuts" ? parts[1] : "";
|
||||
}
|
||||
|
||||
function skillDomainForPath(filePath) {
|
||||
const parts = normalizePath(filePath).split("/");
|
||||
return parts.length >= 2 && parts[0] === "skills" && parts[1].startsWith("lark-")
|
||||
? parts[1].slice("lark-".length)
|
||||
: "";
|
||||
}
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
for (const item of files) {
|
||||
if (item.status !== "added") continue;
|
||||
const domain = shortcutDomainForPath(item.filename);
|
||||
if (!domain) continue;
|
||||
try {
|
||||
await fs.access(path.join(ROOT, "shortcuts", domain));
|
||||
} catch {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function collectCoreAreas(filenames) {
|
||||
const areas = new Set();
|
||||
for (const name of filenames) {
|
||||
const normalized = normalizePath(name);
|
||||
for (const prefix of CORE_PREFIXES) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
// remove trailing slash for area name
|
||||
areas.add(prefix.slice(0, -1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return areas;
|
||||
}
|
||||
|
||||
function collectSensitiveKeywords(filenames) {
|
||||
const hits = new Set();
|
||||
for (const name of filenames) {
|
||||
const match = normalizePath(name).match(SENSITIVE_PATTERN);
|
||||
if (match && match[2]) {
|
||||
hits.add(match[2]);
|
||||
}
|
||||
}
|
||||
return [...hits].sort();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Classification Logic
|
||||
// ============================================================================
|
||||
|
||||
function evaluateRules(context) {
|
||||
const {
|
||||
prType, effectiveChanges, lowRiskOnly,
|
||||
domains, headDomains, coreAreas, coreSignals,
|
||||
sensitiveKeywords, sensitive, newShortcutDomain,
|
||||
singleDomain, multiDomain, filenames
|
||||
} = context;
|
||||
|
||||
const reasons = [];
|
||||
let label;
|
||||
|
||||
if (lowRiskOnly && (LOW_RISK_TYPES.has(prType) || effectiveChanges === 0)) {
|
||||
reasons.push("Only low-risk docs, CI, test, or chore paths were changed, with no effective business code or Skill changes");
|
||||
label = "size/S";
|
||||
return { label, reasons };
|
||||
}
|
||||
|
||||
// XL is reserved for architecture-level or global-impact changes.
|
||||
const isXL =
|
||||
effectiveChanges > THRESHOLD_XL ||
|
||||
(prType === "refactor" && sensitive && effectiveChanges >= THRESHOLD_L) ||
|
||||
(coreAreas.size >= 2 && (multiDomain || effectiveChanges >= THRESHOLD_L)) ||
|
||||
(headDomains.length >= 2 && sensitive);
|
||||
|
||||
if (isXL) {
|
||||
if (effectiveChanges > THRESHOLD_XL) reasons.push("Effective business code or Skill changes are far beyond the L threshold");
|
||||
if (prType === "refactor" && sensitive && effectiveChanges >= THRESHOLD_L) reasons.push("Refactor PR touches core or sensitive paths");
|
||||
if (coreAreas.size >= 2) reasons.push("Touches multiple core areas at the same time");
|
||||
if (headDomains.length >= 2) reasons.push("Impacts multiple major business domains");
|
||||
coreSignals.forEach((signal) => reasons.push(`Core area hit: ${signal}`));
|
||||
sensitiveKeywords.forEach((keyword) => reasons.push(`Sensitive keyword hit: ${keyword}`));
|
||||
label = "size/XL";
|
||||
} else if (
|
||||
prType === "refactor" ||
|
||||
effectiveChanges >= THRESHOLD_L ||
|
||||
Boolean(newShortcutDomain) ||
|
||||
multiDomain ||
|
||||
sensitive
|
||||
) {
|
||||
if (prType === "refactor") reasons.push("PR type is refactor");
|
||||
if (effectiveChanges >= THRESHOLD_L) reasons.push(`Effective business code or Skill changes exceed ${THRESHOLD_L} lines`);
|
||||
if (newShortcutDomain) reasons.push(`Introduces a new business domain directory: shortcuts/${newShortcutDomain}/`);
|
||||
if (multiDomain) reasons.push("Touches multiple business domains");
|
||||
coreSignals.forEach((signal) => reasons.push(`Core area hit: ${signal}`));
|
||||
sensitiveKeywords.forEach((keyword) => reasons.push(`Sensitive keyword hit: ${keyword}`));
|
||||
label = "size/L";
|
||||
} else {
|
||||
if (filenames.some(isBusinessSkillPath) || effectiveChanges > 0) {
|
||||
reasons.push("Regular feat, fix, or Skill change within a single business domain");
|
||||
}
|
||||
if (singleDomain && domains.size > 0) {
|
||||
reasons.push(`Impact is limited to a single business domain: ${[...domains].sort().join(", ")}`);
|
||||
}
|
||||
if (effectiveChanges < THRESHOLD_L) {
|
||||
reasons.push(`Effective business code or Skill changes are below ${THRESHOLD_L} lines`);
|
||||
}
|
||||
label = "size/M";
|
||||
}
|
||||
|
||||
return { label, reasons };
|
||||
}
|
||||
|
||||
async function classifyPr(payload, files) {
|
||||
const pr = payload.pull_request;
|
||||
const title = pr.title || "";
|
||||
const prType = parsePrType(title);
|
||||
const filenames = files.map((item) => item.filename || "");
|
||||
const impactedPaths = files.flatMap((item) => {
|
||||
const paths = [item.filename || ""];
|
||||
if (item.status === "renamed" && item.previous_filename) {
|
||||
paths.push(item.previous_filename);
|
||||
}
|
||||
return paths.filter(Boolean);
|
||||
});
|
||||
|
||||
// Filter out docs, tests, and other low-risk paths so the size label tracks business impact.
|
||||
const effectiveChanges = files.reduce(
|
||||
(sum, item) => sum + (isLowRiskPath(item.filename) ? 0 : (item.changes || 0)),
|
||||
0,
|
||||
);
|
||||
const totalChanges = files.reduce((sum, item) => sum + (item.changes || 0), 0);
|
||||
|
||||
const domains = new Set();
|
||||
const businessDomains = new Set();
|
||||
|
||||
for (const name of impactedPaths) {
|
||||
const businessDomain = getBusinessDomain(name);
|
||||
if (businessDomain) {
|
||||
businessDomains.add(businessDomain);
|
||||
domains.add(businessDomain);
|
||||
continue;
|
||||
}
|
||||
|
||||
const shortcutDomain = shortcutDomainForPath(name);
|
||||
if (shortcutDomain) domains.add(shortcutDomain);
|
||||
|
||||
const skillDomain = skillDomainForPath(name);
|
||||
if (skillDomain) domains.add(skillDomain);
|
||||
}
|
||||
|
||||
const coreAreas = collectCoreAreas(impactedPaths);
|
||||
const newShortcutDomain = await detectNewShortcutDomain(files);
|
||||
|
||||
const lowRiskOnly = impactedPaths.length > 0 && impactedPaths.every(isLowRiskPath);
|
||||
const singleDomain = domains.size <= 1;
|
||||
const multiDomain = domains.size >= 2;
|
||||
const headDomains = [...domains].filter((domain) => HEAD_BUSINESS_DOMAINS.has(domain));
|
||||
const coreSignals = [...coreAreas].sort();
|
||||
const sensitiveKeywords = collectSensitiveKeywords(impactedPaths);
|
||||
const sensitive = coreSignals.length > 0 || sensitiveKeywords.length > 0;
|
||||
|
||||
const context = {
|
||||
prType, effectiveChanges, lowRiskOnly,
|
||||
domains, headDomains, coreAreas, coreSignals,
|
||||
sensitiveKeywords, sensitive, newShortcutDomain,
|
||||
singleDomain, multiDomain, filenames: impactedPaths
|
||||
};
|
||||
|
||||
const { label, reasons } = evaluateRules(context);
|
||||
|
||||
return {
|
||||
label,
|
||||
title,
|
||||
prType: prType || "unknown",
|
||||
totalChanges,
|
||||
effectiveChanges,
|
||||
domains: [...domains].sort(),
|
||||
businessDomains: [...businessDomains].sort(),
|
||||
coreAreas: [...coreAreas].sort(),
|
||||
coreSignals,
|
||||
sensitiveKeywords,
|
||||
newShortcutDomain,
|
||||
reasons,
|
||||
lowRiskOnly,
|
||||
filenames,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Output & Formatting
|
||||
// ============================================================================
|
||||
|
||||
async function writeStepSummary(prNumber, classification) {
|
||||
const summaryPath = (process.env.GITHUB_STEP_SUMMARY || "").trim();
|
||||
if (!summaryPath) return;
|
||||
|
||||
const standard = CLASS_STANDARDS[classification.label];
|
||||
const domains = classification.domains.join(", ") || "-";
|
||||
const bDomains = classification.businessDomains.join(", ") || "-";
|
||||
const coreAreas = classification.coreAreas.join(", ") || "-";
|
||||
const reasons = classification.reasons.length > 0
|
||||
? classification.reasons
|
||||
: ["No higher-severity rule matched, so the PR defaults to medium classification"];
|
||||
|
||||
const lines = [
|
||||
"## PR Size Classification",
|
||||
"",
|
||||
`- PR: #${prNumber}`,
|
||||
`- Label: \`${classification.label}\``,
|
||||
`- PR Type: \`${classification.prType}\``,
|
||||
`- Total Changes: \`${classification.totalChanges}\``,
|
||||
`- Effective Business/SKILL Changes: \`${classification.effectiveChanges}\``,
|
||||
`- Business Domains: \`${domains}\``,
|
||||
`- Impacted Domains: \`${bDomains}\``,
|
||||
`- Core Areas: \`${coreAreas}\``,
|
||||
`- CI/CD Channel: \`${standard.channel}\``,
|
||||
`- Low Risk Only: \`${classification.lowRiskOnly}\``,
|
||||
"",
|
||||
"### Reasons",
|
||||
"",
|
||||
...reasons.map((reason) => `- ${reason}`),
|
||||
"",
|
||||
"### Pipeline Gates",
|
||||
"",
|
||||
...standard.gates.map((gate) => `- ${gate}`),
|
||||
"",
|
||||
];
|
||||
|
||||
await fs.appendFile(summaryPath, `${lines.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
function formatDryRunResult(repo, prNumber, classification) {
|
||||
const standard = CLASS_STANDARDS[classification.label];
|
||||
return {
|
||||
repo,
|
||||
prNumber,
|
||||
label: classification.label,
|
||||
prType: classification.prType,
|
||||
totalChanges: classification.totalChanges,
|
||||
effectiveChanges: classification.effectiveChanges,
|
||||
lowRiskOnly: classification.lowRiskOnly,
|
||||
domains: classification.domains,
|
||||
businessDomains: classification.businessDomains,
|
||||
coreAreas: classification.coreAreas,
|
||||
coreSignals: classification.coreSignals,
|
||||
sensitiveKeywords: classification.sensitiveKeywords,
|
||||
reasons: classification.reasons,
|
||||
channel: standard.channel,
|
||||
gates: standard.gates,
|
||||
};
|
||||
}
|
||||
|
||||
function printDryRunResult(result, options) {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const signalParts = [
|
||||
...result.coreSignals.map((signal) => `core:${signal}`),
|
||||
...result.sensitiveKeywords.map((keyword) => `keyword:${keyword}`),
|
||||
...(result.domains.length > 0 ? [`domains:${result.domains.join(",")}`] : []),
|
||||
];
|
||||
const reasonParts = result.reasons.length > 0
|
||||
? result.reasons
|
||||
: ["No higher-severity rule matched, so the PR defaults to medium classification"];
|
||||
|
||||
console.log(
|
||||
`${result.label} | #${result.prNumber} | type:${result.prType} | eff:${result.effectiveChanges} | `
|
||||
+ `sig:${signalParts.join(";") || "-"} | reason:${reasonParts.join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
const lines = [
|
||||
"Usage:",
|
||||
" node scripts/pr-labels/index.js",
|
||||
" node scripts/pr-labels/index.js --dry-run --pr-url <github-pr-url> [--token <token>] [--json]",
|
||||
" node scripts/pr-labels/index.js --dry-run --repo <owner/name> --pr-number <number> [--token <token>] [--json]",
|
||||
"",
|
||||
"Modes:",
|
||||
" default Read the GitHub Actions event payload and apply labels",
|
||||
" --dry-run Fetch the PR, compute the managed label, and print the result without writing labels",
|
||||
"",
|
||||
"Options:",
|
||||
" --pr-url <url> GitHub pull request URL, for example https://github.com/larksuite/cli/pull/123",
|
||||
" --repo <owner/name> Repository name, used with --pr-number",
|
||||
" --pr-number <n> Pull request number, used with --repo",
|
||||
" --token <token> GitHub token override; falls back to GITHUB_TOKEN",
|
||||
" --json Print dry-run output as JSON instead of the default one-line summary",
|
||||
" --help Show this message",
|
||||
];
|
||||
console.log(lines.join("\n"));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
dryRun: false,
|
||||
json: false,
|
||||
help: false,
|
||||
prUrl: "",
|
||||
repo: "",
|
||||
prNumber: "",
|
||||
token: "",
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--dry-run") options.dryRun = true;
|
||||
else if (arg === "--json") options.json = true;
|
||||
else if (arg === "--help" || arg === "-h") options.help = true;
|
||||
else if (arg === "--pr-url") options.prUrl = argv[++i] || "";
|
||||
else if (arg === "--repo") options.repo = argv[++i] || "";
|
||||
else if (arg === "--pr-number") options.prNumber = argv[++i] || "";
|
||||
else if (arg === "--token") options.token = argv[++i] || "";
|
||||
else throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function parsePrUrl(prUrl) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(prUrl);
|
||||
} catch {
|
||||
throw new Error(`invalid PR URL: ${prUrl}`);
|
||||
}
|
||||
|
||||
const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
|
||||
if (!match) throw new Error(`unsupported PR URL format: ${prUrl}`);
|
||||
|
||||
return { repo: `${match[1]}/${match[2]}`, prNumber: Number(match[3]) };
|
||||
}
|
||||
|
||||
async function loadEventPayload(filePath) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
async function resolveContext(options) {
|
||||
const token = options.token;
|
||||
|
||||
if (options.prUrl) {
|
||||
const { repo, prNumber } = parsePrUrl(options.prUrl);
|
||||
const client = new GitHubClient(token, repo, prNumber);
|
||||
const payload = {
|
||||
repository: { full_name: repo },
|
||||
pull_request: await client.getPullRequest(),
|
||||
};
|
||||
return { repo, prNumber, payload, client };
|
||||
}
|
||||
|
||||
if (options.repo || options.prNumber) {
|
||||
if (!options.repo || !options.prNumber) throw new Error("--repo and --pr-number must be provided together");
|
||||
const prNumber = Number(options.prNumber);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error(`invalid PR number: ${options.prNumber}`);
|
||||
|
||||
const client = new GitHubClient(token, options.repo, prNumber);
|
||||
const payload = {
|
||||
repository: { full_name: options.repo },
|
||||
pull_request: await client.getPullRequest(),
|
||||
};
|
||||
return { repo: options.repo, prNumber, payload, client };
|
||||
}
|
||||
|
||||
const eventPath = envOrFail("GITHUB_EVENT_PATH");
|
||||
const payload = await loadEventPayload(eventPath);
|
||||
const repo = payload.repository.full_name;
|
||||
const prNumber = payload.pull_request.number;
|
||||
const client = new GitHubClient(token, repo, prNumber);
|
||||
|
||||
return { repo, prNumber, payload, client };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Execution
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
options.token = options.token || envValue("GITHUB_TOKEN");
|
||||
|
||||
if (!options.dryRun && !options.token) {
|
||||
throw new Error("missing required GitHub token; set GITHUB_TOKEN or pass --token");
|
||||
}
|
||||
|
||||
const { repo, prNumber, payload, client } = await resolveContext(options);
|
||||
|
||||
const files = await client.listPrFiles();
|
||||
const classification = await classifyPr(payload, files);
|
||||
|
||||
if (options.dryRun) {
|
||||
printDryRunResult(formatDryRunResult(repo, prNumber, classification), options);
|
||||
return;
|
||||
}
|
||||
|
||||
const desired = new Set([classification.label]);
|
||||
for (const domain of classification.businessDomains) {
|
||||
desired.add(`domain/${domain}`);
|
||||
}
|
||||
|
||||
const current = await client.listIssueLabels();
|
||||
const managedCurrent = [...current].filter((label) => MANAGED_LABELS.has(label) || label.startsWith("domain/"));
|
||||
const toAdd = [...desired].filter((label) => !current.has(label)).sort();
|
||||
const toRemove = managedCurrent.filter((label) => !desired.has(label)).sort();
|
||||
|
||||
for (const domain of classification.businessDomains) {
|
||||
const labelName = `domain/${domain}`;
|
||||
if (!LABEL_DEFINITIONS[labelName]) {
|
||||
LABEL_DEFINITIONS[labelName] = { color: "1d76db", description: `PR touches the ${domain} domain` };
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure labels to be added actually exist in the repository first
|
||||
// If the label doesn't exist, GitHub API will return 422 Unprocessable Entity when trying to add it to a PR.
|
||||
for (const label of toAdd) {
|
||||
if (LABEL_DEFINITIONS[label]) {
|
||||
try {
|
||||
await client.syncLabelDefinition(label);
|
||||
} catch (e) {
|
||||
log(`Warning: Failed to bootstrap new label ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.addLabels(toAdd);
|
||||
|
||||
for (const label of toRemove) {
|
||||
await client.removeLabel(label);
|
||||
}
|
||||
|
||||
// Keep other label metadata consistent. This is best-effort trailing work.
|
||||
for (const label of Object.keys(LABEL_DEFINITIONS)) {
|
||||
if (toAdd.includes(label)) continue; // Already synced above
|
||||
try {
|
||||
await client.syncLabelDefinition(label);
|
||||
} catch (e) {
|
||||
log(`Warning: Failed to sync label definition for ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await writeStepSummary(prNumber, classification);
|
||||
|
||||
log(
|
||||
`pr #${prNumber} type=${classification.prType} total_changes=${classification.totalChanges} `
|
||||
+ `effective_changes=${classification.effectiveChanges} files=${files.length} `
|
||||
+ `desired=${[...desired].sort().join(",") || "-"} current_managed=${managedCurrent.sort().join(",") || "-"} `
|
||||
+ `reasons=${classification.reasons.join(" | ") || "-"}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
log(error.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
145
scripts/pr-labels/samples.json
Normal file
145
scripts/pr-labels/samples.json
Normal file
@@ -0,0 +1,145 @@
|
||||
[
|
||||
{
|
||||
"name": "size-s-docs-badge",
|
||||
"number": 103,
|
||||
"title": "docs: add official badge to distinguish from third-party Lark CLI tools",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/103",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T12:15:45Z",
|
||||
"expected_label": "size/S",
|
||||
"expected_domains": [],
|
||||
"review_note": "Pure docs sample. Useful to confirm low-risk paths stay in S even when total changed lines are not tiny."
|
||||
},
|
||||
{
|
||||
"name": "size-s-docs-simplify",
|
||||
"number": 26,
|
||||
"title": "docs: simplify installation steps by merging CLI and Skills into one …",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/26",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-28T09:33:24Z",
|
||||
"expected_label": "size/S",
|
||||
"expected_domains": [],
|
||||
"review_note": "Docs sample, verifying docs changes remain in S."
|
||||
},
|
||||
{
|
||||
"name": "size-s-docs-star-history",
|
||||
"number": 12,
|
||||
"title": "docs: add Star History chart to readmes",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/12",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-28T16:00:15Z",
|
||||
"expected_label": "size/S",
|
||||
"expected_domains": [],
|
||||
"review_note": "Docs sample, no effective business code changes."
|
||||
},
|
||||
{
|
||||
"name": "size-s-docs-clarify-install",
|
||||
"number": 3,
|
||||
"title": "docs: clarify install methods and add source build steps",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/3",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-28T03:43:44Z",
|
||||
"expected_label": "size/S",
|
||||
"expected_domains": [],
|
||||
"review_note": "Docs sample, pure documentation clarification."
|
||||
},
|
||||
{
|
||||
"name": "size-m-fix-base-scope",
|
||||
"number": 96,
|
||||
"title": "fix(base): correct scope for record history list shortcut",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/96",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T11:40:18Z",
|
||||
"expected_label": "size/M",
|
||||
"expected_domains": ["domain/base"],
|
||||
"review_note": "Small fix sample. Verify the lower edge of the M bucket within a single domain."
|
||||
},
|
||||
{
|
||||
"name": "size-m-fix-mail-sensitive",
|
||||
"number": 92,
|
||||
"title": "fix: remove sensitive send scope from reply and forward shortcuts",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/92",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T10:19:11Z",
|
||||
"expected_label": "size/M",
|
||||
"expected_domains": ["domain/mail"],
|
||||
"review_note": "Security-like wording in the title but stays in one business domain (mail)."
|
||||
},
|
||||
{
|
||||
"name": "size-m-ci-improve",
|
||||
"number": 71,
|
||||
"title": "ci: improve CI workflows and add golangci-lint config",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/71",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T03:09:31Z",
|
||||
"expected_label": "size/M",
|
||||
"expected_domains": [],
|
||||
"review_note": "CI workflow change that goes beyond S threshold."
|
||||
},
|
||||
{
|
||||
"name": "size-m-feat-im-pagination",
|
||||
"number": 30,
|
||||
"title": "feat: add auto-pagination to messages search and update lark-im docs",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/30",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T15:00:41Z",
|
||||
"expected_label": "size/M",
|
||||
"expected_domains": ["domain/im"],
|
||||
"review_note": "Single-domain feature with larger diff but effective changes stay in M."
|
||||
},
|
||||
{
|
||||
"name": "size-l-fix-api-silent",
|
||||
"number": 85,
|
||||
"title": "fix: resolve silent failure in `lark-cli api` error output (#39)",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/85",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-30T09:19:24Z",
|
||||
"expected_label": "size/L",
|
||||
"expected_domains": [],
|
||||
"review_note": "Touches core area (cmd), bumping the size to L."
|
||||
},
|
||||
{
|
||||
"name": "size-l-fix-cli",
|
||||
"number": 91,
|
||||
"title": "fix: correct CLI examples in root help and READMEs (closes #48)",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/91",
|
||||
"status": "closed",
|
||||
"merged_at": null,
|
||||
"expected_label": "size/L",
|
||||
"expected_domains": [],
|
||||
"review_note": "Closed PR touching core area (cmd)."
|
||||
},
|
||||
{
|
||||
"name": "size-m-skill-format-check",
|
||||
"number": 134,
|
||||
"title": "feat(ci): add skill format check workflow to ensure SKILL.md compliance",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/134",
|
||||
"status": "closed",
|
||||
"merged_at": null,
|
||||
"expected_label": "size/M",
|
||||
"expected_domains": [],
|
||||
"review_note": "Includes updates to tests/bad-skill/SKILL.md inside skills-like paths, testing how skill mock files and test scripts are handled."
|
||||
},
|
||||
{
|
||||
"name": "size-l-ccm-multi-path",
|
||||
"number": 57,
|
||||
"title": "feat(docs): support local image upload in docs +create",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/57",
|
||||
"status": "closed",
|
||||
"merged_at": null,
|
||||
"expected_label": "size/L",
|
||||
"expected_domains": ["domain/ccm"],
|
||||
"review_note": "Touches docs_create_images.go and table_auto_width.go, representing multiple CCM sub-paths but resolving to a single ccm domain."
|
||||
},
|
||||
{
|
||||
"name": "size-l-domain-rename",
|
||||
"number": 11,
|
||||
"title": "docs: rename user-facing Bitable references to Base",
|
||||
"pr_url": "https://github.com/larksuite/cli/pull/11",
|
||||
"status": "merged",
|
||||
"merged_at": "2026-03-28T16:00:52Z",
|
||||
"expected_label": "size/L",
|
||||
"expected_domains": ["domain/base", "domain/ccm"],
|
||||
"review_note": "A rename across paths. Since we track previous_filename to evaluate domains, this should properly capture the base domain."
|
||||
}
|
||||
]
|
||||
52
scripts/pr-labels/test.js
Normal file
52
scripts/pr-labels/test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const fs = require('fs');
|
||||
const { execFileSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const samplesPath = path.join(__dirname, 'samples.json');
|
||||
const indexPath = path.join(__dirname, 'index.js');
|
||||
const samples = JSON.parse(fs.readFileSync(samplesPath, 'utf8'));
|
||||
|
||||
if (!process.env.GITHUB_TOKEN) {
|
||||
console.error("❌ Error: GITHUB_TOKEN environment variable is required to run tests without hitting API rate limits.");
|
||||
console.error("Please run: GITHUB_TOKEN=$(gh auth token) node scripts/pr-labels/test.js");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const sample of samples) {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
[indexPath, '--dry-run', '--json', '--pr-url', sample.pr_url],
|
||||
{ encoding: 'utf8', env: process.env }
|
||||
);
|
||||
const result = JSON.parse(output);
|
||||
|
||||
const matchLabel = result.label === sample.expected_label;
|
||||
|
||||
// Sort before comparing to ignore order
|
||||
const actualDomains = (result.businessDomains || []).sort();
|
||||
const expectedDomains = (sample.expected_domains || []).map(d => d.replace('domain/', '')).sort();
|
||||
|
||||
const matchDomains = JSON.stringify(actualDomains) === JSON.stringify(expectedDomains);
|
||||
|
||||
if (matchLabel && matchDomains) {
|
||||
console.log(`✅ Passed: ${sample.name}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`❌ Failed: ${sample.name}`);
|
||||
console.log(` Label expected: ${sample.expected_label}, got: ${result.label}`);
|
||||
console.log(` Domains expected: ${expectedDomains}, got: ${actualDomains}`);
|
||||
failed++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`❌ Failed: ${sample.name} (Execution error)`);
|
||||
console.error(e.message);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTest Summary: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
36
scripts/skill-format-check/README.md
Normal file
36
scripts/skill-format-check/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Skill Format Check
|
||||
|
||||
This directory contains a script to validate the format of `SKILL.md` files located in the `../../skills` directory.
|
||||
|
||||
## Purpose
|
||||
|
||||
The `index.js` script ensures that all `SKILL.md` files conform to the standard template defined in `skill-template/skill-template.md`. Specifically, it checks that the YAML frontmatter includes the following fields:
|
||||
- `name` (required)
|
||||
- `description` (required)
|
||||
- `metadata` (outputs a warning if missing, does not fail the build)
|
||||
|
||||
> **Note:** The `lark-shared` skill is explicitly excluded from these format checks.
|
||||
|
||||
## Usage
|
||||
|
||||
This script is executed automatically via GitHub Actions (`.github/workflows/skill-format-check.yml`) on pull requests and pushes that modify the `skills/` directory.
|
||||
|
||||
To run the check manually from the root of the repository, execute:
|
||||
|
||||
```bash
|
||||
node scripts/skill-format-check/index.js
|
||||
```
|
||||
|
||||
You can also specify a custom target directory as the first argument:
|
||||
|
||||
```bash
|
||||
node scripts/skill-format-check/index.js ./path/to/my/skills
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
This tool comes with a quick validation script to ensure it correctly identifies good and bad skill formats. To run the tests, execute:
|
||||
|
||||
```bash
|
||||
./scripts/skill-format-check/test.sh
|
||||
```
|
||||
96
scripts/skill-format-check/index.js
Normal file
96
scripts/skill-format-check/index.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Allow passing a target directory as the first argument.
|
||||
// If provided, resolve against process.cwd() so it behaves as the user expects.
|
||||
// If not provided, default to '../../skills' relative to this script's directory.
|
||||
const targetDirArg = process.argv[2];
|
||||
const SKILLS_DIR = targetDirArg
|
||||
? path.resolve(process.cwd(), targetDirArg)
|
||||
: path.resolve(__dirname, '../../skills');
|
||||
|
||||
function checkSkillFormat() {
|
||||
console.log(`Checking skill format in ${SKILLS_DIR}...`);
|
||||
|
||||
if (!fs.existsSync(SKILLS_DIR)) {
|
||||
console.error('Skills directory not found:', SKILLS_DIR);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let skills;
|
||||
try {
|
||||
skills = fs
|
||||
.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name);
|
||||
} catch (err) {
|
||||
console.error(`Failed to enumerate skills directory: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
skills.forEach(skill => {
|
||||
// Skip lark-shared skill completely
|
||||
if (skill === 'lark-shared') {
|
||||
console.log(`⏭️ Skipping check for ${skill}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const skillPath = path.join(SKILLS_DIR, skill);
|
||||
const skillFile = path.join(skillPath, 'SKILL.md');
|
||||
|
||||
if (!fs.existsSync(skillFile)) {
|
||||
console.error(`❌ [${skill}] Missing SKILL.md`);
|
||||
hasErrors = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(skillFile, 'utf-8');
|
||||
} catch (err) {
|
||||
console.error(`❌ [${skill}] Failed to read SKILL.md: ${err.message}`);
|
||||
hasErrors = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize line endings to simplify parsing
|
||||
const normalizedContent = content.replace(/\r\n/g, '\n');
|
||||
|
||||
// Check YAML Frontmatter
|
||||
if (!normalizedContent.startsWith('---\n')) {
|
||||
console.error(`❌ [${skill}] SKILL.md must start with YAML frontmatter (---)`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
const frontmatterMatch = normalizedContent.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
||||
if (!frontmatterMatch) {
|
||||
console.error(`❌ [${skill}] SKILL.md has unclosed or invalid YAML frontmatter`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
if (!/^name:/m.test(frontmatter)) {
|
||||
console.error(`❌ [${skill}] YAML frontmatter missing 'name'`);
|
||||
hasErrors = true;
|
||||
}
|
||||
if (!/^description:/m.test(frontmatter)) {
|
||||
console.error(`❌ [${skill}] YAML frontmatter missing 'description'`);
|
||||
hasErrors = true;
|
||||
}
|
||||
if (!/^metadata:/m.test(frontmatter)) {
|
||||
console.warn(`⚠️ [${skill}] YAML frontmatter missing 'metadata' (Warning only)`);
|
||||
// hasErrors = true; // Downgrade to warning to not fail on existing skills
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasErrors) {
|
||||
console.error('\n❌ Skill format check failed. Please fix the errors above.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ Skill format check passed!');
|
||||
}
|
||||
}
|
||||
|
||||
checkSkillFormat();
|
||||
82
scripts/skill-format-check/test.sh
Executable file
82
scripts/skill-format-check/test.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get the directory of this script
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
INDEX_JS="$DIR/index.js"
|
||||
TEMP_DIR="$DIR/tests/temp_test_dir"
|
||||
|
||||
echo "=== Running tests for skill-format-check ==="
|
||||
echo "Index script: $INDEX_JS"
|
||||
|
||||
prepare_fixture() {
|
||||
local test_name=$1
|
||||
rm -rf "$TEMP_DIR"
|
||||
mkdir -p "$TEMP_DIR"
|
||||
if [ ! -d "$DIR/tests/$test_name" ]; then
|
||||
echo "❌ Missing fixture directory: $DIR/tests/$test_name"
|
||||
exit 1
|
||||
fi
|
||||
cp -r "$DIR/tests/$test_name" "$TEMP_DIR/" || {
|
||||
echo "❌ Failed to copy fixture: $test_name"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Function to run a positive test
|
||||
run_positive_test() {
|
||||
local test_name=$1
|
||||
echo -e "\n--- [Positive] $test_name ---"
|
||||
|
||||
prepare_fixture "$test_name"
|
||||
|
||||
node "$INDEX_JS" "$TEMP_DIR"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Passed! (Correctly validated $test_name)"
|
||||
rm -rf "$TEMP_DIR"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Failed! Expected $test_name to pass but it failed."
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run a negative test
|
||||
run_negative_test() {
|
||||
local test_name=$1
|
||||
echo -e "\n--- [Negative] $test_name ---"
|
||||
|
||||
prepare_fixture "$test_name"
|
||||
|
||||
# Capture output for diagnostics while still treating non-zero as expected
|
||||
local log_file="$TEMP_DIR/.validator.log"
|
||||
node "$INDEX_JS" "$TEMP_DIR" > "$log_file" 2>&1
|
||||
local exit_code=$?
|
||||
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo "✅ Passed! (Correctly rejected $test_name)"
|
||||
rm -rf "$TEMP_DIR"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Failed! Expected $test_name to fail but it passed."
|
||||
if [ -s "$log_file" ]; then
|
||||
echo "--- Validator output ---"
|
||||
cat "$log_file"
|
||||
fi
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run positive tests
|
||||
run_positive_test "good-skill"
|
||||
run_positive_test "good-skill-minimal"
|
||||
run_positive_test "good-skill-complex"
|
||||
|
||||
# Run negative tests
|
||||
run_negative_test "bad-skill"
|
||||
run_negative_test "bad-skill-no-frontmatter"
|
||||
run_negative_test "bad-skill-unclosed-frontmatter"
|
||||
|
||||
echo -e "\n🎉 All tests passed successfully!"
|
||||
@@ -0,0 +1,3 @@
|
||||
# No Frontmatter Skill
|
||||
|
||||
This skill completely lacks a YAML frontmatter.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: bad-skill-unclosed
|
||||
version: 1.0.0
|
||||
description: "This skill has an unclosed frontmatter block."
|
||||
metadata: {}
|
||||
|
||||
# Unclosed Frontmatter Skill
|
||||
|
||||
This frontmatter does not have a closing `---` block.
|
||||
8
scripts/skill-format-check/tests/bad-skill/SKILL.md
Normal file
8
scripts/skill-format-check/tests/bad-skill/SKILL.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
version: 1.0.0
|
||||
metadata: {}
|
||||
---
|
||||
|
||||
# Bad Skill
|
||||
|
||||
This skill is missing required fields like name and description.
|
||||
17
scripts/skill-format-check/tests/good-skill-complex/SKILL.md
Normal file
17
scripts/skill-format-check/tests/good-skill-complex/SKILL.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: good-skill-complex
|
||||
version: 2.5.1-beta
|
||||
description: >
|
||||
A very complex description
|
||||
that spans multiple lines
|
||||
and contains weird chars: !@#$%^&*()
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli", "node"]
|
||||
cliHelp: "lark-cli something --help"
|
||||
customField: "customValue"
|
||||
---
|
||||
|
||||
# Complex Skill
|
||||
|
||||
This skill has a complex frontmatter block.
|
||||
10
scripts/skill-format-check/tests/good-skill-minimal/SKILL.md
Normal file
10
scripts/skill-format-check/tests/good-skill-minimal/SKILL.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: good-skill-minimal
|
||||
version: 0.1.0
|
||||
description: Minimal valid description
|
||||
metadata: {}
|
||||
---
|
||||
|
||||
# Minimal Skill
|
||||
|
||||
This has the bare minimum required fields.
|
||||
12
scripts/skill-format-check/tests/good-skill/SKILL.md
Normal file
12
scripts/skill-format-check/tests/good-skill/SKILL.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: good-skill
|
||||
version: 1.0.0
|
||||
description: "This is a properly formatted skill."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
---
|
||||
|
||||
# Good Skill
|
||||
|
||||
This skill follows all the formatting rules.
|
||||
@@ -4,6 +4,10 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -88,3 +92,39 @@ func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
|
||||
t.Fatal("base workspace shortcut not mounted on existing service command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
output := os.Getenv("SHORTCUTS_OUTPUT")
|
||||
if output == "" {
|
||||
t.Skip("set SHORTCUTS_OUTPUT env to generate shortcuts.json")
|
||||
}
|
||||
|
||||
shortcuts := AllShortcuts()
|
||||
|
||||
type entry struct {
|
||||
Verb string `json:"verb"`
|
||||
Description string `json:"description"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
grouped := make(map[string][]entry)
|
||||
for _, s := range shortcuts {
|
||||
verb := strings.TrimPrefix(s.Command, "+")
|
||||
grouped[s.Service] = append(grouped[s.Service], entry{
|
||||
Verb: verb,
|
||||
Description: s.Description,
|
||||
Scopes: s.ScopesForIdentity("user"),
|
||||
})
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(grouped, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal shortcuts: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(output, data, 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
t.Logf("wrote %d bytes to %s", len(data), output)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user