修正版本計算未分頁導致最新版誤判為 0.0.0 #3

Merged
jiantw83 merged 4 commits from ai-review-resolve/20260623-170920 into develop 2026-06-23 09:20:44 +00:00
12 changed files with 229 additions and 248 deletions
-14
View File
@@ -1,14 +0,0 @@
# Triage Findings
When the task is to triage review findings, follow this workflow:
1. Merge all findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1 after sorting.
5. Fix real issues with the smallest safe change.
6. Add false positives to `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
7. Add or update tests when behavior changes.
8. Re-check the issue after each fix.
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
-29
View File
@@ -1,29 +0,0 @@
---
name: triage-findings
description: Triage findings, fix real issues, and exclude false positives.
---
# Triage Findings
## Use
直接輸入:`triage-findings 問題原始檔(文字或截圖)`
## Workflow
1. Merge all findings.
2. Sort by severity:
- critical
- warning
- info
3. Renumber from 1.
4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
6. Add tests when behavior changes.
## Output Rules
- Keep the final list short.
- Keep numbering contiguous.
- Preserve file path, location, and fix.
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
-45
View File
@@ -1,45 +0,0 @@
---
name: triage-findings
description: Merge code-review findings, sort and renumber them by severity, resolve real issues, and move false positives into exclusions.
---
# Triage Findings
## When To Use
Use this skill when you receive multiple review findings, screenshots, comments, or issue lists that need to become one final triaged list.
It is also used when some findings are false positives and should be moved into the exclusions list.
## Workflow
1. Collect all findings into one list.
2. Merge duplicates into a single finding when they describe the same issue.
3. Sort the final list by severity:
- critical
- warning
- info
4. Renumber the sorted list from 1 upward.
5. Rewrite each finding concisely so the final list reads cleanly and consistently.
6. If a finding is a false positive, do not keep it in the final list.
7. Add false positives to the exclusions list using the existing schema in the repo or task context, and preserve the original finding wording as much as possible, including language and semantics.
## Resolution Flow
After the list is merged and ordered, resolve the remaining findings one by one.
1. Start from the highest severity item.
2. Identify the root cause in the relevant file or context.
3. Apply the smallest safe change that fixes the issue.
4. Add or update tests when behavior changes.
5. Re-check the issue after the change.
6. If the item is confirmed false positive, move it to exclusions instead of changing code.
7. Continue until the list is either fixed or explicitly excluded.
## Output Rules
- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable.
- Keep numbering contiguous after filtering and merging.
- Preserve useful details like file path, location, and suggested fix.
- Keep exclusions entries minimal and consistent with the project schema.
- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema.
- If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
@@ -1,4 +0,0 @@
interface:
display_name: "Triage Findings"
short_description: "Triage, sort, fix, and exclude review findings"
default_prompt: "Use $triage-findings to merge review findings, sort and renumber them by severity, resolve real issues one by one, and add false positives to exclusions."
-29
View File
@@ -1,29 +0,0 @@
---
name: triage-findings
description: Triage findings, fix real issues, and exclude false positives.
---
# Triage Findings
## Use
直接輸入:`triage-findings 問題原始檔(文字或截圖)`
## Workflow
1. Merge all findings.
2. Sort by severity:
- critical
- warning
- info
3. Renumber from 1.
4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
6. Add tests when behavior changes.
## Output Rules
- Keep the final list short.
- Keep numbering contiguous.
- Preserve file path, location, and fix.
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
+4 -4
View File
@@ -14,10 +14,10 @@ jobs:
with: with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }},${{ secrets.GEMINI_API_KEY_10 }},${{ secrets.GEMINI_API_KEY_11 }},${{ secrets.GEMINI_API_KEY_12 }},${{ secrets.GEMINI_API_KEY_13 }},${{ secrets.GEMINI_API_KEY_14 }},${{ secrets.GEMINI_API_KEY_15 }},${{ secrets.GEMINI_API_KEY_16 }},${{ secrets.GEMINI_API_KEY_17 }},${{ secrets.GEMINI_API_KEY_18 }},${{ secrets.GEMINI_API_KEY_19 }} OPENCODE_BASE_URL: ${{ vars.OPENCODE_BASE_URL }}
GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta OPENCODE_PROVIDER: ${{ vars.OPENCODE_PROVIDER }}
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} OPENCODE_MODEL: ${{ vars.GEMINI_MODEL }}
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
issues: write issues: write
-14
View File
@@ -1,14 +0,0 @@
# Triage Findings
Use the triage-finding workflow for review issue lists:
1. Merge findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1.
5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
7. Add or update tests when behavior changes.
8. Re-check after each fix.
The full reusable skill lives in `.claude/skills/triage-findings/SKILL.md`.
-14
View File
@@ -1,14 +0,0 @@
# Triage Findings
Use the triage-finding workflow for review issue lists:
1. Merge findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1.
5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
7. Add or update tests when behavior changes.
8. Re-check after each fix.
The reusable skill lives in `.gemini/skills/triage-findings/SKILL.md`.
-16
View File
@@ -1,16 +0,0 @@
# Triage Findings
When the task is to triage review findings, follow this workflow:
1. Merge all findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1 after sorting.
5. Fix real issues with the smallest safe change.
6. Add false positives to `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
7. Add or update tests when behavior changes.
8. Re-check the issue after each fix.
Use the repo-local `triage-findings` skill for the same workflow when running in Codex.
Trigger it with `/triage-findings`.
-14
View File
@@ -1,14 +0,0 @@
# Triage Findings
Use the triage-finding workflow for review issue lists:
1. Merge findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1.
5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
7. Add or update tests when behavior changes.
8. Re-check after each fix.
The reusable skill lives in `.gemini/skills/triage-findings/SKILL.md`.
+75 -20
View File
@@ -4,6 +4,7 @@ set -euo pipefail
readonly LINE="==================================================" readonly LINE="=================================================="
readonly SUBLINE="--------------------------------------------------" readonly SUBLINE="--------------------------------------------------"
readonly RELEASES_PER_PAGE=10
section() { section() {
printf '\n%s\n%s\n%s\n' "$LINE" "$1" "$SUBLINE" printf '\n%s\n%s\n%s\n' "$LINE" "$1" "$SUBLINE"
@@ -53,13 +54,17 @@ latest_stable_version() {
fi fi
printf '%s' "$release_json" | jq -r ' printf '%s' "$release_json" | jq -r '
if type == "array" then ( if type == "array" then . else [] end )
[ .[] | select(.tag_name? and (.tag_name | test("-beta\\.") | not)) | .tag_name ][0] | [ .[]
// "v0.0.0" | select(.tag_name? and (.tag_name | test("-beta\\.") | not))
else | .tag_name
"v0.0.0" | sub("^v"; "")
end | select(test("^[0-9]+(\\.[0-9]+)*$"))
| sub("^v"; "") | split(".")
| map(tonumber)
]
| if length == 0 then [0, 0, 0] else (sort | last) end
| "\(.[0] // 0).\(.[1] // 0).\(.[2] // 0)"
' '
} }
@@ -124,13 +129,17 @@ calculate_version() {
printf '%s' "$release_json" | jq -r --arg is_beta "$is_beta" ' printf '%s' "$release_json" | jq -r --arg is_beta "$is_beta" '
def stable_version: def stable_version:
if type == "array" then ( if type == "array" then . else [] end )
[ .[] | select(.tag_name? and (.tag_name | test("-beta\\.") | not)) | .tag_name ][0] | [ .[]
// "v0.0.0" | select(.tag_name? and (.tag_name | test("-beta\\.") | not))
else | .tag_name
"v0.0.0" | sub("^v"; "")
end | select(test("^[0-9]+(\\.[0-9]+)*$"))
| sub("^v"; ""); | split(".")
| map(tonumber)
]
| if length == 0 then [0, 0, 0] else (sort | last) end
| "\(.[0] // 0).\(.[1] // 0).\(.[2] // 0)";
def next_release($latest): def next_release($latest):
($latest | split(".") | map(tonumber? // 0)) as $parts ($latest | split(".") | map(tonumber? // 0)) as $parts
@@ -166,6 +175,56 @@ calculate_version() {
' '
} }
fetch_releases() {
local base_url="$1"
local -a auth_args=()
if [ -n "${RUNNER_TOKEN:-}" ] && [ "${RUNNER_TOKEN:-}" != "null" ]; then
info "使用授權 token 取得 release" >&2
auth_args=(-H "Authorization: token $RUNNER_TOKEN")
else
info "使用匿名請求取得 release" >&2
fi
local page=1
local combined="[]"
local page_json count
while :; do
if ! page_json="$(curl -fsS ${auth_args[@]+"${auth_args[@]}"} "${base_url}?limit=${RELEASES_PER_PAGE}&page=${page}")"; then
fail "release API 請求失敗 (page=${page})"
fi
# 空字串或 null 代表已無更多資料
if [ -z "$page_json" ] || [ "$page_json" = "null" ]; then
break
fi
if ! count="$(printf '%s' "$page_json" | jq 'if type == "array" then length else -1 end')"; then
fail "release API 回傳資料無法解析 (page=${page})"
fi
if [ "$count" = "-1" ]; then
fail "release API 回傳非陣列資料 (page=${page})"
fi
info "${page} 頁取得 ${count} 筆 release" >&2
if ! combined="$(jq -n --argjson acc "$combined" --argjson page "$page_json" '$acc + $page')"; then
fail "合併 release 資料失敗 (page=${page})"
fi
# 不足一頁代表已取完
if [ "$count" -lt "$RELEASES_PER_PAGE" ]; then
break
fi
page=$((page + 1))
done
printf '%s' "$combined"
}
main() { main() {
section "參數檢查" section "參數檢查"
@@ -186,12 +245,8 @@ main() {
RELEASE_URL="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases" RELEASE_URL="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases"
info "RELEASE_URL=$RELEASE_URL" info "RELEASE_URL=$RELEASE_URL"
if [ -n "${RUNNER_TOKEN:-}" ] && [ "${RUNNER_TOKEN:-}" != "null" ]; then if ! RELEASE_JSON="$(fetch_releases "$RELEASE_URL")"; then
info "使用授權 token 取得 release" fail "取得 release 資料失敗"
RELEASE_JSON="$(curl -fsS -H "Authorization: token $RUNNER_TOKEN" "$RELEASE_URL")"
else
info "使用匿名請求取得 release"
RELEASE_JSON="$(curl -fsS "$RELEASE_URL")"
fi fi
VERSION_INFO="$(calculate_version "$RELEASE_JSON" "$IS_BETA")" VERSION_INFO="$(calculate_version "$RELEASE_JSON" "$IS_BETA")"
+150 -45
View File
@@ -33,7 +33,6 @@ assert_eq() {
make_mock_curl() { make_mock_curl() {
local bin_dir="$1" local bin_dir="$1"
local response_file="$2"
cat >"$bin_dir/curl" <<'EOF' cat >"$bin_dir/curl" <<'EOF'
#!/bin/sh #!/bin/sh
@@ -45,6 +44,26 @@ if [ "${FAKE_CURL_STATUS:-0}" != "0" ]; then
exit "$FAKE_CURL_STATUS" exit "$FAKE_CURL_STATUS"
fi fi
# 分頁模式:依 URL 上的 page=N 回傳對應的 page 檔,超出範圍回傳空陣列
if [ -n "${FAKE_CURL_PAGES_DIR:-}" ]; then
page=1
for arg in "$@"; do
case "$arg" in
*page=*)
page=${arg##*page=}
page=${page%%&*}
;;
esac
done
page_file="$FAKE_CURL_PAGES_DIR/$page.json"
if [ -f "$page_file" ]; then
cat "$page_file"
else
printf '[]'
fi
exit 0
fi
cat "${FAKE_CURL_RESPONSE_FILE:?}" cat "${FAKE_CURL_RESPONSE_FILE:?}"
EOF EOF
chmod +x "$bin_dir/curl" chmod +x "$bin_dir/curl"
@@ -55,42 +74,63 @@ make_mock_jq() {
cat >"$bin_dir/jq" <<'EOF' cat >"$bin_dir/jq" <<'EOF'
#!/bin/sh #!/bin/sh
is_beta="" python3 -c '
query="" import json, re, sys
while [ "$#" -gt 0 ]; do args = sys.argv[1:]
case "$1" in named = {}
-r) use_stdin = True
shift i = 0
;; while i < len(args):
--arg) a = args[i]
if [ "$2" = "is_beta" ]; then if a == "-r":
is_beta="$3" i += 1
fi elif a == "-n":
shift 3 use_stdin = False
;; i += 1
*) elif a in ("--arg", "--argjson"):
query="$1" value = args[i + 2]
shift named[args[i + 1]] = json.loads(value) if a == "--argjson" else value
break i += 3
;; else:
esac i += 1
done
python3 -c 'import json, sys payload = sys.stdin.read() if use_stdin else ""
query = sys.argv[1]
is_beta = sys.argv[2] def parse_or_exit(text):
payload = sys.stdin.read() try:
try: return json.loads(text)
data = json.loads(payload) except Exception:
except Exception: sys.exit(4)
sys.exit(4)
# merge 模式:$acc + $page
if "acc" in named and "page" in named:
sys.stdout.write(json.dumps(named["acc"] + named["page"]))
sys.exit(0)
# count 模式:陣列長度,非陣列回 -1
if "is_beta" not in named:
data = parse_or_exit(payload)
sys.stdout.write(str(len(data)) if isinstance(data, list) else "-1")
sys.exit(0)
# calculate 模式
data = parse_or_exit(payload)
is_beta = named.get("is_beta", "")
def to_tuple(ver):
nums = []
for part in ver.split("."):
try:
nums.append(int(part))
except Exception:
nums.append(0)
while len(nums) < 3:
nums.append(0)
return tuple(nums[:3])
def next_version(latest): def next_version(latest):
parts = [int(p or 0) for p in latest.split(".")] major, minor, patch = to_tuple(latest)
while len(parts) < 3:
parts.append(0)
major, minor, patch = parts[:3]
patch += 1 patch += 1
if patch >= 10: if patch >= 10:
patch = 0 patch = 0
@@ -98,18 +138,17 @@ def next_version(latest):
if minor >= 10: if minor >= 10:
minor = 0 minor = 0
major += 1 major += 1
return f"{major}.{minor}.{patch}" return "{}.{}.{}".format(major, minor, patch)
def beta_max(data, prefix): def beta_max(items, prefix):
values = [] values = []
for item in data: for item in items:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
tag = item.get("tag_name") tag = item.get("tag_name")
if isinstance(tag, str) and tag.startswith(prefix): if isinstance(tag, str) and tag.startswith(prefix):
suffix = tag[len(prefix):]
try: try:
values.append(int(suffix)) values.append(int(tag[len(prefix):]))
except Exception: except Exception:
pass pass
return max(values) if values else 0 return max(values) if values else 0
@@ -117,22 +156,24 @@ def beta_max(data, prefix):
if not isinstance(data, list): if not isinstance(data, list):
base = "0.0.0" base = "0.0.0"
else: else:
base = "0.0.0" stable = []
for item in data: for item in data:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
tag = item.get("tag_name") tag = item.get("tag_name")
if isinstance(tag, str) and "-beta." not in tag: if isinstance(tag, str) and "-beta." not in tag:
base = tag[1:] if tag.startswith("v") else tag ver = tag[1:] if tag.startswith("v") else tag
break if re.match(r"^[0-9]+(\.[0-9]+)*$", ver):
stable.append(ver)
base = max(stable, key=to_tuple) if stable else "0.0.0"
next_ver = next_version(base) next_ver = next_version(base)
if is_beta == "true": if is_beta == "true":
beta = beta_max(data, f"v{next_ver}-beta.") + 1 beta = beta_max(data, "v{}-beta.".format(next_ver)) + 1
sys.stdout.write(f"{base}\t{next_ver}-beta.{beta}") sys.stdout.write("{}\t{}-beta.{}".format(base, next_ver, beta))
else: else:
sys.stdout.write(f"{base}\t{next_ver}") sys.stdout.write("{}\t{}".format(base, next_ver))
' "$query" "$is_beta" ' "$@"
EOF EOF
chmod +x "$bin_dir/jq" chmod +x "$bin_dir/jq"
} }
@@ -281,6 +322,69 @@ test_null_release_payload() {
assert_eq "version=0.0.1" "$(cat "$output_file")" "null release payload" assert_eq "version=0.0.1" "$(cat "$output_file")" "null release payload"
} }
test_pagination_collects_all_pages() {
local workdir
local pages_dir
local bin_dir
local output_file
local stdout_file
local stderr_file
local curl_args
local i
workdir="$(mktemp -d)"
CLEANUP_PATHS+=("$workdir")
pages_dir="$workdir/pages"
mkdir -p "$pages_dir"
# 第 1 頁滿載 10 筆,且全部是 beta(重現「最新一整頁都是 beta」的情境)
{
printf '['
for i in $(seq 1 10); do
if [ "$i" -gt 1 ]; then printf ','; fi
printf '{"tag_name":"v1.9.0-beta.%s"}' "$i"
done
printf ']'
} >"$pages_dir/1.json"
# 第 2 頁才出現穩定版,且故意讓最大值不是第一筆(驗證 semver 取最大)
cat >"$pages_dir/2.json" <<'JSON'
[
{"tag_name":"v1.2.3"},
{"tag_name":"v1.3.1"},
{"tag_name":"v1.2.9"}
]
JSON
bin_dir="$workdir/bin"
mkdir -p "$bin_dir"
make_mock_curl "$bin_dir"
make_mock_jq "$bin_dir"
output_file="$workdir/github_output"
stdout_file="$workdir/stdout"
stderr_file="$workdir/stderr"
FAKE_CURL_STATUS=0 \
FAKE_CURL_PAGES_DIR="$pages_dir" \
FAKE_CURL_LOG_FILE="$workdir/curl_args" \
PATH="$bin_dir:$PATH" \
GITEA_SERVER_URL="https://gitea.example.com" \
GITEA_REPOSITORY="org/repo" \
IS_BETA="false" \
GITHUB_OUTPUT="$output_file" \
bash "$ROOT_DIR/entrypoint.sh" >"$stdout_file" 2>"$stderr_file"
# 穩定版在第 2 頁、最大為 1.3.1 -> 下一版 1.3.2
assert_eq "version=1.3.2" "$(cat "$output_file")" "pagination + semver max output"
# 確認確實翻到第 2 頁
curl_args="$workdir/curl_args"
if ! grep -q 'page=2' "$curl_args"; then
fail "pagination: expected a request for page=2"
fi
}
test_token_auth_header() { test_token_auth_header() {
local workdir local workdir
local response_file local response_file
@@ -392,6 +496,7 @@ test_beta_release_flow
test_empty_release_list test_empty_release_list
test_only_beta_releases test_only_beta_releases
test_null_release_payload test_null_release_payload
test_pagination_collects_all_pages
test_token_auth_header test_token_auth_header
test_malformed_release_payload test_malformed_release_payload
test_curl_failure test_curl_failure