trae-coding-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +91 -0
  2. package/bin/coding-engine.js +16 -0
  3. package/lib/cli.js +86 -0
  4. package/lib/index.js +6 -0
  5. package/lib/setup.js +290 -0
  6. package/package.json +32 -0
  7. package/templates/.agents/skills/coding-architecture/SKILL.md +347 -0
  8. package/templates/.agents/skills/coding-code-review/SKILL.md +95 -0
  9. package/templates/.agents/skills/coding-cve-remediation/SKILL.md +163 -0
  10. package/templates/.agents/skills/coding-db-schema/SKILL.md +273 -0
  11. package/templates/.agents/skills/coding-deploy-config/SKILL.md +129 -0
  12. package/templates/.agents/skills/coding-docs-audit/SKILL.md +285 -0
  13. package/templates/.agents/skills/coding-docs-audit-autofix/SKILL.md +33 -0
  14. package/templates/.agents/skills/coding-http-api/SKILL.md +269 -0
  15. package/templates/.agents/skills/coding-issue-autodev/SKILL.md +172 -0
  16. package/templates/.agents/skills/coding-merge-request/SKILL.md +199 -0
  17. package/templates/.agents/skills/coding-observability/SKILL.md +193 -0
  18. package/templates/.agents/skills/coding-refactor-insight/SKILL.md +89 -0
  19. package/templates/.agents/skills/coding-release-merge/SKILL.md +224 -0
  20. package/templates/.agents/skills/coding-runtime-adapter/SKILL.md +115 -0
  21. package/templates/.agents/skills/coding-testing/SKILL.md +169 -0
  22. package/templates/AGENTS.md +179 -0
  23. package/templates/MR-Template-Default.md +33 -0
  24. package/templates/Makefile +370 -0
  25. package/templates/REGISTRY.md +53 -0
  26. package/templates/scripts/check-adr.sh +283 -0
  27. package/templates/scripts/check-comment-i18n.py +209 -0
  28. package/templates/scripts/check-comment-i18n.sh +38 -0
  29. package/templates/scripts/check-doc-refs.sh +40 -0
  30. package/templates/scripts/check-docs.sh +103 -0
  31. package/templates/scripts/check-go-names.sh +59 -0
  32. package/templates/scripts/check-iam-actions.py +126 -0
  33. package/templates/scripts/check-migration-sql-immutability.sh +232 -0
  34. package/templates/scripts/check-mr-desc.sh +165 -0
  35. package/templates/scripts/check-mr-size.sh +128 -0
  36. package/templates/scripts/check-mr-title.sh +123 -0
  37. package/templates/scripts/check-openapi.py +221 -0
  38. package/templates/scripts/check-rdb-structured-queries.sh +98 -0
  39. package/templates/scripts/check-repository-transactions.sh +100 -0
  40. package/templates/scripts/check-runbooks.sh +229 -0
  41. package/templates/scripts/check-skill-router-sync.py +331 -0
  42. package/templates/scripts/check-skills.sh +243 -0
  43. package/templates/scripts/docs-audit-to-issues.py +373 -0
  44. package/templates/scripts/docs-verification-reminder.sh +63 -0
  45. package/templates/scripts/find-ci-failures.sh +133 -0
  46. package/templates/scripts/find-stale-mrs.sh +72 -0
  47. package/templates/scripts/gen-adr-index.sh +122 -0
  48. package/templates/scripts/open-mr.sh +536 -0
  49. package/templates/scripts/regen-agent-md.sh +149 -0
  50. package/templates/scripts/run-mr-hygiene.sh +140 -0
  51. package/templates/scripts/runbook.sh +91 -0
  52. package/templates/scripts/self-maintenance-digest.py +125 -0
  53. package/templates/scripts/send-self-maintenance-lark-card.py +116 -0
  54. package/templates/scripts/skill-graph.sh +114 -0
  55. package/templates/scripts/skill-impact.sh +134 -0
  56. package/templates/scripts/skill-propose.sh +302 -0
  57. package/templates/scripts/skill-router-hook.sh +152 -0
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ fail=0
5
+
6
+ is_generated_or_frontend_path() {
7
+ local path="$1"
8
+ case "$path" in
9
+ web/*|*/web/*|\
10
+ .agents/*|*/.agents/*|\
11
+ .codex/*|*/.codex/*|\
12
+ */thrift_gen/*|thrift_gen/*|\
13
+ */third_party/*|third_party/*|\
14
+ */vendor/*|vendor/*|\
15
+ */node_modules/*|node_modules/*)
16
+ return 0
17
+ ;;
18
+ esac
19
+ return 1
20
+ }
21
+
22
+ while IFS= read -r file; do
23
+ if is_generated_or_frontend_path "$file"; then
24
+ continue
25
+ fi
26
+
27
+ dir=$(dirname "$file")
28
+ base=$(basename "$dir")
29
+ if [[ "$base" == *"_"* || "$base" =~ [A-Z] ]]; then
30
+ printf 'Go directory name must not use snake_case or uppercase: %s\n' "$dir" >&2
31
+ fail=1
32
+ fi
33
+
34
+ pkg=$(awk '/^package[[:space:]]+/ { print $2; exit }' "$file")
35
+ if [[ -z "$pkg" ]]; then
36
+ continue
37
+ fi
38
+ if [[ ! "$pkg" =~ ^[a-z][a-z0-9]*(_test)?$ ]]; then
39
+ printf 'Go package name must be lowercase alnum, with optional _test suffix: %s: package %s\n' "$file" "$pkg" >&2
40
+ fail=1
41
+ fi
42
+ if [[ "$pkg" != "main" ]]; then
43
+ want="$base"
44
+ if [[ "$pkg" == *_test ]]; then
45
+ want="${base}_test"
46
+ fi
47
+ if [[ "$pkg" != "$want" ]]; then
48
+ printf 'Go package name must match directory name: %s: package %s, want %s\n' "$file" "$pkg" "$want" >&2
49
+ fail=1
50
+ fi
51
+ fi
52
+ done < <(find . \
53
+ -path './.git' -prune -o \
54
+ -path './.agents' -prune -o \
55
+ -path './.codex' -prune -o \
56
+ -path './web' -prune -o \
57
+ -type f -name '*.go' -print | sed 's#^\./##' | sort)
58
+
59
+ exit "$fail"
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ """Check TOP Action synchronization between server.thrift and IAM registration."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ IDL_METHOD_RE = re.compile(r"^\s*[\w.]+\s+([A-Z]\w*)\s*\(", re.MULTILINE)
13
+ IAM_ACTION_RE = re.compile(r"^\s*-\s+Action:\s*([A-Za-z0-9_]+)\s*$")
14
+ IAM_FIELD_RE = re.compile(r"^\s{6}([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$")
15
+
16
+
17
+ def read_text(path: Path) -> str:
18
+ try:
19
+ return path.read_text(encoding="utf-8")
20
+ except OSError as exc:
21
+ raise SystemExit(f"check-iam-actions: cannot read {path}: {exc}") from exc
22
+
23
+
24
+ def thrift_methods(path: Path) -> set[str]:
25
+ return set(IDL_METHOD_RE.findall(read_text(path)))
26
+
27
+
28
+ def iam_actions(path: Path, ignored_path_prefixes: tuple[str, ...]) -> dict[str, str]:
29
+ actions: dict[str, str] = {}
30
+ current_action: str | None = None
31
+ current_path = ""
32
+
33
+ def flush() -> None:
34
+ nonlocal current_action, current_path
35
+ if current_action is None:
36
+ return
37
+ if not ignored_iam_path(current_path, ignored_path_prefixes):
38
+ actions[current_action] = current_path
39
+ current_action = None
40
+ current_path = ""
41
+
42
+ for line in read_text(path).splitlines():
43
+ action_match = IAM_ACTION_RE.match(line)
44
+ if action_match:
45
+ flush()
46
+ current_action = action_match.group(1)
47
+ continue
48
+
49
+ if current_action is None:
50
+ continue
51
+
52
+ field_match = IAM_FIELD_RE.match(line)
53
+ if not field_match:
54
+ continue
55
+ key, value = field_match.groups()
56
+ if key == "Path":
57
+ current_path = unquote(value)
58
+
59
+ flush()
60
+ return actions
61
+
62
+
63
+ def unquote(value: str) -> str:
64
+ value = value.strip()
65
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
66
+ return value[1:-1]
67
+ return value
68
+
69
+
70
+ def ignored_iam_path(path: str, prefixes: tuple[str, ...]) -> bool:
71
+ return any(path == prefix or path.startswith(prefix + "/") for prefix in prefixes)
72
+
73
+
74
+ def main() -> int:
75
+ parser = argparse.ArgumentParser(
76
+ description="Check that IDL TOP methods and IAM Actions are synchronized."
77
+ )
78
+ parser.add_argument("--idl", type=Path, default=Path("api/idl/server.thrift"))
79
+ parser.add_argument(
80
+ "--iam",
81
+ type=Path,
82
+ default=Path("deployments/coding-engine/templates/server/iam-register-codingserver.yaml"),
83
+ )
84
+ parser.add_argument(
85
+ "--ignore-iam-path-prefix",
86
+ action="append",
87
+ default=["/v1/agents"],
88
+ help="Ignore IAM Actions whose Path equals or starts with this prefix.",
89
+ )
90
+ args = parser.parse_args()
91
+
92
+ idl_action_names = thrift_methods(args.idl)
93
+ iam_action_paths = iam_actions(args.iam, tuple(args.ignore_iam_path_prefix))
94
+ iam_action_names = set(iam_action_paths)
95
+
96
+ missing_iam = sorted(idl_action_names - iam_action_names)
97
+ stale_iam = sorted(iam_action_names - idl_action_names)
98
+
99
+ if not missing_iam and not stale_iam:
100
+ print(
101
+ f"[OK] IAM Actions match IDL methods "
102
+ f"({len(idl_action_names)} IDL, {len(iam_action_names)} IAM after filters)"
103
+ )
104
+ return 0
105
+
106
+ if missing_iam:
107
+ print("IDL methods missing from IAM registration:", file=sys.stderr)
108
+ for action in missing_iam:
109
+ print(f" - {action}", file=sys.stderr)
110
+
111
+ if stale_iam:
112
+ print("IAM Actions not present in IDL methods:", file=sys.stderr)
113
+ for action in stale_iam:
114
+ print(f" - {action} (Path: {iam_action_paths[action] or '<empty>'})", file=sys.stderr)
115
+
116
+ print(
117
+ "Hint: update api/idl/server.thrift and "
118
+ "deployments/coding-engine/templates/server/iam-register-codingserver.yaml together. "
119
+ "IAM Actions with Path under /v1/agents are treated as REST exceptions.",
120
+ file=sys.stderr,
121
+ )
122
+ return 1
123
+
124
+
125
+ if __name__ == "__main__":
126
+ raise SystemExit(main())
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env bash
2
+ # Guard source migration SQL history: committed migration files are append-only.
3
+
4
+ set -euo pipefail
5
+
6
+ BASE="${SQL_IMMUTABILITY_BASE:-origin/release}"
7
+ MIGRATION_DIALECT_RE='(mysql|postgres|dm)'
8
+ VERSIONED_MIGRATION_SQL_RE="^migration/${MIGRATION_DIALECT_RE}/(up|down)/v[0-9]+[.][0-9]+[.][0-9]+/[0-9]{3}[.]sql$"
9
+
10
+ while [ "$#" -gt 0 ]; do
11
+ case "$1" in
12
+ --base)
13
+ [ "$#" -ge 2 ] || { echo "usage: $0 [--base <git-ref>]" >&2; exit 2; }
14
+ BASE="$2"
15
+ shift 2
16
+ ;;
17
+ --base=*)
18
+ BASE="${1#--base=}"
19
+ shift
20
+ ;;
21
+ -h|--help)
22
+ echo "usage: $0 [--base <git-ref>]"
23
+ echo
24
+ echo "Base must be a git ref resolvable in this worktree, for example origin/release."
25
+ echo "SQL_IMMUTABILITY_BASE provides the same value when --base is omitted."
26
+ echo "Set SQL_IMMUTABILITY_REQUIRE_CLEAN=1 to fail when migration/*.sql has local changes."
27
+ echo "Default base is origin/release; use --base or SQL_IMMUTABILITY_BASE for other MR targets."
28
+ echo "The repository must have enough source and target branch history to compute merge-base."
29
+ exit 0
30
+ ;;
31
+ *)
32
+ echo "unknown argument: $1" >&2
33
+ exit 2
34
+ ;;
35
+ esac
36
+ done
37
+
38
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
39
+ echo "check migration SQL immutability: not inside a git worktree" >&2
40
+ exit 2
41
+ fi
42
+
43
+ if ! BASE_COMMIT="$(git rev-parse --verify -q "${BASE}^{commit}")"; then
44
+ cat >&2 <<EOF
45
+ check migration SQL immutability: base ref ${BASE} is not available.
46
+ Fetch the target branch first, for example:
47
+ git fetch --filter=blob:none origin release:refs/remotes/origin/release
48
+ Or pass an available target ref explicitly:
49
+ $0 --base <git-ref>
50
+ EOF
51
+ exit 2
52
+ fi
53
+
54
+ if ! MERGE_BASE="$(git merge-base "$BASE_COMMIT" HEAD)"; then
55
+ cat >&2 <<EOF
56
+ check migration SQL immutability: cannot compute merge-base with ${BASE}.
57
+ Fetch source and target branch history first, for example:
58
+ git fetch --filter=blob:none origin release:refs/remotes/origin/release
59
+ For shallow clones, fetch enough history for both source and target branches.
60
+ EOF
61
+ exit 2
62
+ fi
63
+
64
+ WORK="$(mktemp -d)"
65
+ trap 'rm -rf "$WORK"' EXIT INT TERM
66
+
67
+ violations="$WORK/violations"
68
+ invalid="$WORK/invalid"
69
+ additions="$WORK/additions"
70
+ dirty="$WORK/dirty"
71
+ : >"$violations"
72
+ : >"$invalid"
73
+ : >"$additions"
74
+ : >"$dirty"
75
+
76
+ is_versioned_migration_sql() {
77
+ printf '%s\n' "$1" | grep -Eq "$VERSIONED_MIGRATION_SQL_RE" || return 1
78
+ }
79
+
80
+ status_porcelain="$WORK/status-porcelain"
81
+ if ! git status --porcelain --untracked-files=all -- migration >"$status_porcelain"; then
82
+ echo "check migration SQL immutability: git status failed for migration/" >&2
83
+ exit 2
84
+ fi
85
+ awk -v re="${VERSIONED_MIGRATION_SQL_RE#^}" '$0 ~ re || $0 ~ re " -> " { print }' "$status_porcelain" >"$dirty"
86
+
87
+ if [ -s "$dirty" ]; then
88
+ if [ "${SQL_IMMUTABILITY_REQUIRE_CLEAN:-}" = "1" ]; then
89
+ cat >&2 <<'EOF'
90
+ Migration SQL working tree must be clean before running this guard in strict mode.
91
+ Commit or discard local migration/*.sql changes first; this check validates the
92
+ committed branch diff against the target branch and does not require unrelated
93
+ working tree files to be clean.
94
+
95
+ Dirty migration SQL changes:
96
+ EOF
97
+ sed 's/^/ /' "$dirty" >&2
98
+ exit 2
99
+ fi
100
+
101
+ cat >&2 <<'EOF'
102
+ warning: local migration SQL changes are not included in the committed branch
103
+ immutability check. CI runs this guard in strict mode.
104
+
105
+ Dirty migration SQL changes:
106
+ EOF
107
+ sed 's/^/ /' "$dirty" >&2
108
+ fi
109
+
110
+ diff_status_z="$WORK/diff-status-z"
111
+ if ! git diff -M --name-status -z --diff-filter=DMRT "$MERGE_BASE"...HEAD >"$diff_status_z"; then
112
+ cat >&2 <<EOF
113
+ check migration SQL immutability: git diff failed against base ${BASE}.
114
+ This may happen with bad revisions or partial/shallow clones missing objects.
115
+ Fetch enough history/objects for both source and target branches and retry.
116
+ EOF
117
+ exit 2
118
+ fi
119
+
120
+ while IFS= read -r -d '' status; do
121
+ kind="${status:0:1}"
122
+ case "$kind" in
123
+ M|D|T)
124
+ IFS= read -r -d '' path
125
+ if is_versioned_migration_sql "$path"; then
126
+ printf '%s\t%s\n' "$kind" "$path" >>"$violations"
127
+ fi
128
+ ;;
129
+ R)
130
+ IFS= read -r -d '' old_path
131
+ IFS= read -r -d '' new_path
132
+ if is_versioned_migration_sql "$old_path"; then
133
+ printf '%s\t%s -> %s\n' "$kind" "$old_path" "$new_path" >>"$violations"
134
+ fi
135
+ if ! is_versioned_migration_sql "$new_path" \
136
+ && [ "${new_path#migration/}" != "$new_path" ] \
137
+ && [ "${old_path#migration/}" = "$old_path" ] \
138
+ && printf '%s' "$new_path" | grep -Eq '[.]sql$'; then
139
+ printf '%s\t%s -> %s (renaming an external .sql into migration/ is not allowed; add new migrations only as versioned up/down/vX.Y.Z/NNN.sql paths)\n' "$kind" "$old_path" "$new_path" >>"$violations"
140
+ fi
141
+ if is_versioned_migration_sql "$new_path" && ! is_versioned_migration_sql "$old_path"; then
142
+ printf '%s\t%s -> %s (rename into migration is not allowed; versioned migrations must be added as new files)\n' "$kind" "$old_path" "$new_path" >>"$violations"
143
+ fi
144
+ ;;
145
+ *)
146
+ IFS= read -r -d '' _unused || true
147
+ ;;
148
+ esac
149
+ done <"$diff_status_z"
150
+
151
+ if [ -s "$violations" ]; then
152
+ cat >&2 <<'EOF'
153
+ Existing source migration SQL files are immutable, including comment-only changes.
154
+ Do not modify, delete, or rename SQL files that already exist on the target branch.
155
+ For the same release version, append the next NNN.sql file. For a new version,
156
+ create a new vX.Y.Z directory and start at 001.sql.
157
+
158
+ Rejected changes:
159
+ EOF
160
+ sed 's/^/ /' "$violations" >&2
161
+ exit 1
162
+ fi
163
+
164
+ git diff -M --name-only --diff-filter=A "$MERGE_BASE"...HEAD -- migration \
165
+ | awk '/[.]sql$/ { print }' >"$additions"
166
+
167
+ if [ ! -s "$additions" ]; then
168
+ exit 0
169
+ fi
170
+
171
+ while IFS= read -r path; do
172
+ case "$path" in
173
+ migration/mysql/up/v*/*.sql|migration/mysql/down/v*/*.sql|migration/postgres/up/v*/*.sql|migration/postgres/down/v*/*.sql|migration/dm/up/v*/*.sql|migration/dm/down/v*/*.sql)
174
+ file="${path##*/}"
175
+ version="${path%/*}"
176
+ version="${version##*/}"
177
+ if ! printf '%s\n' "$version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
178
+ printf '%s: version directory must be vX.Y.Z\n' "$path" >>"$invalid"
179
+ fi
180
+ if ! printf '%s\n' "$file" | grep -Eq '^[0-9]{3}[.]sql$'; then
181
+ printf '%s: migration filename must be a three-digit NNN.sql sequence\n' "$path" >>"$invalid"
182
+ fi
183
+ ;;
184
+ *)
185
+ printf '%s: migration SQL must live under migration/{mysql,postgres,dm}/{up,down}/vX.Y.Z/NNN.sql; dialect directory names are lowercase\n' "$path" >>"$invalid"
186
+ ;;
187
+ esac
188
+ done <"$additions"
189
+
190
+ if [ -s "$invalid" ]; then
191
+ cat >&2 <<'EOF'
192
+ New source migration SQL files must use versioned NNN.sql paths:
193
+ EOF
194
+ sed 's/^/ /' "$invalid" >&2
195
+ exit 1
196
+ fi
197
+
198
+ awk -F/ '{ print $1 "/" $2 "/" $3 "/" $4 }' "$additions" | sort -u >"$WORK/dirs"
199
+
200
+ while IFS= read -r dir; do
201
+ base_max="$(
202
+ git ls-tree -r --name-only "$BASE_COMMIT" -- "$dir" \
203
+ | awk -F/ '
204
+ /[0-9][0-9][0-9][.]sql$/ {
205
+ n = $NF
206
+ sub(/[.]sql$/, "", n)
207
+ if ((n + 0) > max) max = n + 0
208
+ }
209
+ END { print max + 0 }
210
+ '
211
+ )"
212
+ expected=$((base_max + 1))
213
+ while IFS= read -r num; do
214
+ actual=$((10#$num))
215
+ if [ "$actual" -ne "$expected" ]; then
216
+ printf '%s/%03d.sql: expected next migration file, found %03d.sql\n' "$dir" "$expected" "$actual" >>"$invalid"
217
+ expected=$((actual + 1))
218
+ continue
219
+ fi
220
+ expected=$((expected + 1))
221
+ done < <(awk -F/ -v dir="$dir" '$0 ~ "^" dir "/" { print $NF }' "$additions" | sed 's/[.]sql$//' | sort -n)
222
+ done <"$WORK/dirs"
223
+
224
+ if [ -s "$invalid" ]; then
225
+ cat >&2 <<'EOF'
226
+ New migration SQL files must be appended in sequence.
227
+ For an existing version directory, use the next NNN.sql after the target branch.
228
+ For a new version directory, start at 001.sql and keep new files contiguous.
229
+ EOF
230
+ sed 's/^/ /' "$invalid" >&2
231
+ exit 1
232
+ fi
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env bash
2
+ # Hard guardrail: block MRs whose description is missing, left as the raw
3
+ # template, or whose 变更说明 section contains only placeholder comments.
4
+ #
5
+ # Pairs with scripts/open-mr.sh (producer-side apply helper); this is
6
+ # the receiver-side gate, invoked from .gitlab-ci.yml on every MR pipeline.
7
+ #
8
+ # Description source, checked in order:
9
+ # 1. --description-file <path> ('-' reads STDIN)
10
+ # 2. GitLab API fetch (MR_LINT_TOKEN + CI_MERGE_REQUEST_IID set; CI-only)
11
+ # 3. CI_MERGE_REQUEST_DESCRIPTION env var (GitLab built-in; ~2700-char trunc)
12
+ #
13
+ # Exit codes:
14
+ # 0 description is acceptable
15
+ # 1 description fails one or more checks -- MR should be blocked
16
+ # 2 bad usage / missing dependency / not running on an MR
17
+ #
18
+ # Local usage:
19
+ # scripts/check-mr-desc.sh --description-file /tmp/desc.md
20
+ # echo "..." | scripts/check-mr-desc.sh --description-file -
21
+
22
+ set -euo pipefail
23
+ cd "$(dirname "$0")/.."
24
+
25
+ # shellcheck source=scripts/lib/cli-args.sh
26
+ . scripts/lib/cli-args.sh
27
+
28
+ TEMPLATE_DIR=".gitlab/merge_request_templates"
29
+ MIN_BODY_CHARS=20
30
+
31
+ DESC_FILE=""
32
+ while [ $# -gt 0 ]; do
33
+ case "$1" in
34
+ --description-file)
35
+ DESC_FILE=$(require_value --description-file "${2:-}"); shift 2 ;;
36
+ --description-file=*)
37
+ DESC_FILE="${1#--description-file=}"; shift ;;
38
+ -h|--help)
39
+ sed -n '2,21p' "$0"; exit 0 ;;
40
+ *) echo "unknown arg: $1" >&2; exit 2 ;;
41
+ esac
42
+ done
43
+
44
+ description=""
45
+ source_label=""
46
+
47
+ if [ -n "$DESC_FILE" ]; then
48
+ if [ "$DESC_FILE" = "-" ]; then
49
+ description=$(cat)
50
+ source_label="stdin"
51
+ else
52
+ [ -f "$DESC_FILE" ] || { echo "error: file not found: $DESC_FILE" >&2; exit 2; }
53
+ description=$(cat "$DESC_FILE")
54
+ source_label="file:$DESC_FILE"
55
+ fi
56
+ elif [ -n "${MR_LINT_TOKEN:-}" ] && [ -n "${CI_MERGE_REQUEST_IID:-}" ]; then
57
+ # CI-only path: bytedcli isn't on the alpine runner. curl+jq are installed
58
+ # by .gitlab-ci.yml's before_script only when MR_LINT_TOKEN is set.
59
+ api_base="${CI_API_V4_URL:-https://code.byted.org/api/v4}"
60
+ project_id="${CI_MERGE_REQUEST_PROJECT_ID:-${CI_PROJECT_ID:-}}"
61
+ if [ -z "$project_id" ]; then
62
+ echo "error: MR_LINT_TOKEN set but no CI_MERGE_REQUEST_PROJECT_ID/CI_PROJECT_ID" >&2
63
+ exit 2
64
+ fi
65
+ command -v curl >/dev/null || { echo "missing curl" >&2; exit 2; }
66
+ command -v jq >/dev/null || { echo "missing jq" >&2; exit 2; }
67
+ api_url="${api_base}/projects/${project_id}/merge_requests/${CI_MERGE_REQUEST_IID}"
68
+ resp=$(curl -fsS --max-time 20 \
69
+ --header "PRIVATE-TOKEN: ${MR_LINT_TOKEN}" \
70
+ "$api_url") || {
71
+ echo "error: failed to fetch MR via API: $api_url" >&2
72
+ exit 2
73
+ }
74
+ # `jq -e` exits 1 on null/empty -- catches API shape drift before it
75
+ # manifests downstream as a misleading "description is empty" verdict.
76
+ description=$(jq -er '.description // empty' <<<"$resp") || {
77
+ echo "error: MR API response had no .description field" >&2
78
+ exit 2
79
+ }
80
+ source_label="api:!${CI_MERGE_REQUEST_IID}"
81
+ elif [ -n "${CI_MERGE_REQUEST_DESCRIPTION:-}" ]; then
82
+ description="$CI_MERGE_REQUEST_DESCRIPTION"
83
+ source_label="env:CI_MERGE_REQUEST_DESCRIPTION (may be truncated)"
84
+ elif [ -n "${CI_MERGE_REQUEST_IID:-}" ]; then
85
+ echo "error: running on an MR pipeline but no description source available." >&2
86
+ echo " set CI_MERGE_REQUEST_DESCRIPTION, or provide MR_LINT_TOKEN for API fetch." >&2
87
+ exit 2
88
+ else
89
+ echo "error: not running on an MR pipeline and no --description-file given." >&2
90
+ echo " see --help for local usage." >&2
91
+ exit 2
92
+ fi
93
+
94
+ # Used for template-equality comparison only.
95
+ normalize() {
96
+ tr -d '\r' \
97
+ | sed -E 's/[[:space:]]+/ /g' \
98
+ | sed -E 's/^ +//; s/ +$//'
99
+ }
100
+
101
+ desc_norm=$(printf '%s' "$description" | normalize)
102
+
103
+ failures=()
104
+
105
+ if [ -z "$desc_norm" ]; then
106
+ failures+=("description is empty")
107
+ fi
108
+
109
+ if [ -d "$TEMPLATE_DIR" ]; then
110
+ for tpl in "$TEMPLATE_DIR"/*.md; do
111
+ [ -f "$tpl" ] || continue
112
+ tpl_norm=$(normalize <"$tpl")
113
+ if [ -n "$tpl_norm" ] && [ "$desc_norm" = "$tpl_norm" ]; then
114
+ failures+=("description is unchanged from template: ${tpl}")
115
+ break
116
+ fi
117
+ done
118
+ fi
119
+
120
+ # When 变更说明 header is present, only its section counts as prose; otherwise
121
+ # fall back to checking the whole document. Either way the strip+count pipeline
122
+ # below runs exactly once.
123
+ if grep -q '^## 变更说明' <<<"$description"; then
124
+ section_body=$(awk '
125
+ /^## 变更说明[[:space:]]*$/ { capture=1; next }
126
+ capture && /^---[[:space:]]*$/ { exit }
127
+ capture && /^## / { exit }
128
+ capture { print }
129
+ ' <<<"$description")
130
+ else
131
+ section_body="$description"
132
+ fi
133
+
134
+ # One pass: strip multi-line HTML comments, drop the `**类型:**` metadata line.
135
+ body_clean=$(perl -0777 -pe 's/<!--.*?-->//gs; s/^\*\*类型:\*\*.*\n?//mg' <<<"$section_body")
136
+ body_chars=$(printf '%s' "$body_clean" | tr -d '[:space:]' | wc -c | tr -d ' ')
137
+
138
+ if [ "${body_chars:-0}" -lt "$MIN_BODY_CHARS" ]; then
139
+ failures+=("变更说明 section has <${MIN_BODY_CHARS} chars of real content (got ${body_chars:-0}); only placeholder comments?")
140
+ fi
141
+
142
+ printf " source: %s\n" "$source_label"
143
+ printf " chars: %d (raw)\n" "$(printf '%s' "$description" | wc -c | tr -d ' ')"
144
+ printf " body chars: %d (变更说明, after stripping comments)\n" "${body_chars:-0}"
145
+
146
+ if [ "${#failures[@]}" -eq 0 ]; then
147
+ echo " [OK] MR description passes lint"
148
+ exit 0
149
+ fi
150
+
151
+ {
152
+ echo
153
+ echo " [BLOCK] MR description failed ${#failures[@]} check(s):"
154
+ for f in "${failures[@]}"; do
155
+ echo " - $f"
156
+ done
157
+ echo
158
+ echo " How to fix:"
159
+ echo " 1. Open the MR and edit Description in the GitLab UI, or"
160
+ echo " 2. Draft desc.md from the actual branch diff and apply it with \`make open-mr ARGS=\"--body-file desc.md\"\`."
161
+ echo " 3. Iterate locally: \`make check-mr-desc FILE=desc.md\` (or pipe via --description-file -)."
162
+ echo
163
+ echo " Template lives at: ${TEMPLATE_DIR}/Default.md"
164
+ } >&2
165
+ exit 1