-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(ci): match /review commands followed by a newline #8723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -78,6 +78,15 @@ jobs: | |||||||||||||||||||
| # KEEP IN SYNC with review-pr.if (explicit-trigger branches). | ||||||||||||||||||||
| # Authorization is delegated to the `authorize` job (write+ permission); | ||||||||||||||||||||
| # this `if` only matches the /review command shape. | ||||||||||||||||||||
| # | ||||||||||||||||||||
| # The command may be followed by a newline and a body, so the shape match | ||||||||||||||||||||
| # accepts that — via fromJSON, because expression string literals are NOT | ||||||||||||||||||||
| # escape-processed: '\n' there is a literal backslash + n, and the branch | ||||||||||||||||||||
| # written that way never matched anything. fromJSON('"\n"') is JSON, which | ||||||||||||||||||||
| # IS escape-processed, so it yields a real newline. Both line endings are | ||||||||||||||||||||
| # listed: the API sends LF, the web UI sends CRLF, and startsWith with an | ||||||||||||||||||||
| # LF pattern does not match a CRLF body. Measured on a live runner, not | ||||||||||||||||||||
| # assumed — see the PR that introduced this. | ||||||||||||||||||||
| needs: ['authorize'] | ||||||||||||||||||||
| if: |- | ||||||||||||||||||||
| !cancelled() && | ||||||||||||||||||||
|
|
@@ -87,17 +96,20 @@ jobs: | |||||||||||||||||||
| github.event.issue.state == 'open' && | ||||||||||||||||||||
| (github.event.comment.body == '@qwen-code /review' || | ||||||||||||||||||||
| startsWith(github.event.comment.body, '@qwen-code /review ') || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n')))) || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', fromJSON('"\n"'))) || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', fromJSON('"\r"'))))) || | ||||||||||||||||||||
| (github.event_name == 'pull_request_review_comment' && | ||||||||||||||||||||
| github.event.pull_request.state == 'open' && | ||||||||||||||||||||
| (github.event.comment.body == '@qwen-code /review' || | ||||||||||||||||||||
| startsWith(github.event.comment.body, '@qwen-code /review ') || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n')))) || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', fromJSON('"\n"'))) || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', fromJSON('"\r"'))))) || | ||||||||||||||||||||
| (github.event_name == 'pull_request_review' && | ||||||||||||||||||||
| github.event.pull_request.state == 'open' && | ||||||||||||||||||||
| (github.event.review.body == '@qwen-code /review' || | ||||||||||||||||||||
| startsWith(github.event.review.body, '@qwen-code /review ') || | ||||||||||||||||||||
| startsWith(github.event.review.body, format('@qwen-code /review{0}', '\n'))))) | ||||||||||||||||||||
| startsWith(github.event.review.body, format('@qwen-code /review{0}', fromJSON('"\n"'))) || | ||||||||||||||||||||
| startsWith(github.event.review.body, format('@qwen-code /review{0}', fromJSON('"\r"')))))) | ||||||||||||||||||||
| concurrency: | ||||||||||||||||||||
| group: 'qwen-pr-ack-${{ github.event.issue.number || github.event.pull_request.number }}' | ||||||||||||||||||||
| cancel-in-progress: false | ||||||||||||||||||||
|
|
@@ -315,7 +327,8 @@ jobs: | |||||||||||||||||||
| # review_requested events check the requester and skip delay): | ||||||||||||||||||||
| # - opened/synchronize uses delay-automatic-review | ||||||||||||||||||||
| # - reopened/ready_for_review runs immediately | ||||||||||||||||||||
| # KEEP IN SYNC with ack-review-request.if (explicit-trigger branches). | ||||||||||||||||||||
| # KEEP IN SYNC with ack-review-request.if (explicit-trigger branches) — | ||||||||||||||||||||
| # including the fromJSON newline/CR pair, explained there. | ||||||||||||||||||||
| if: |- | ||||||||||||||||||||
| !cancelled() && | ||||||||||||||||||||
| ((github.event_name == 'workflow_dispatch' && | ||||||||||||||||||||
|
|
@@ -335,19 +348,22 @@ jobs: | |||||||||||||||||||
| github.event.issue.state == 'open' && | ||||||||||||||||||||
| (github.event.comment.body == '@qwen-code /review' || | ||||||||||||||||||||
| startsWith(github.event.comment.body, '@qwen-code /review ') || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n'))) && | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', fromJSON('"\n"'))) || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', fromJSON('"\r"')))) && | ||||||||||||||||||||
| needs.authorize.outputs.should_review == 'true') || | ||||||||||||||||||||
| (github.event_name == 'pull_request_review_comment' && | ||||||||||||||||||||
| github.event.pull_request.state == 'open' && | ||||||||||||||||||||
| (github.event.comment.body == '@qwen-code /review' || | ||||||||||||||||||||
| startsWith(github.event.comment.body, '@qwen-code /review ') || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', '\n'))) && | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', fromJSON('"\n"'))) || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /review{0}', fromJSON('"\r"')))) && | ||||||||||||||||||||
| needs.authorize.outputs.should_review == 'true') || | ||||||||||||||||||||
| (github.event_name == 'pull_request_review' && | ||||||||||||||||||||
| github.event.pull_request.state == 'open' && | ||||||||||||||||||||
| (github.event.review.body == '@qwen-code /review' || | ||||||||||||||||||||
| startsWith(github.event.review.body, '@qwen-code /review ') || | ||||||||||||||||||||
| startsWith(github.event.review.body, format('@qwen-code /review{0}', '\n'))) && | ||||||||||||||||||||
| startsWith(github.event.review.body, format('@qwen-code /review{0}', fromJSON('"\n"'))) || | ||||||||||||||||||||
| startsWith(github.event.review.body, format('@qwen-code /review{0}', fromJSON('"\r"')))) && | ||||||||||||||||||||
| needs.authorize.outputs.should_review == 'true')) | ||||||||||||||||||||
| # The per-review budget auto-scales to QWEN_REVIEW_MAX_TIMEOUT_MINUTES | ||||||||||||||||||||
| # for any non-small PR (see "Run review"), and the shared retry budget | ||||||||||||||||||||
|
|
@@ -450,7 +466,13 @@ jobs: | |||||||||||||||||||
| # review" is an explicit ask (authorize write-permission-checks the | ||||||||||||||||||||
| # requester for exactly that action), so it is excluded below. | ||||||||||||||||||||
| AUTO_REVIEW=false | ||||||||||||||||||||
| # First line only, then drop a trailing CR: comments written in the | ||||||||||||||||||||
| # GitHub web UI arrive CRLF-terminated, so without this the command | ||||||||||||||||||||
|
Comment on lines
+469
to
+470
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The new LF/CR shape matches make multi-line Failure scenario: a commenter writes
Suggested change
中文说明本 PR 让多行 — deepseek-v4-flash via Qwen Code /review (v0.21.7) |
||||||||||||||||||||
| # line keeps a `\r`. That rides through word splitting (IFS has no | ||||||||||||||||||||
| # CR) into tokens like `--timeout=300<CR>`, which then fails the | ||||||||||||||||||||
| # numeric check for no visible reason. | ||||||||||||||||||||
| TRIGGER_COMMAND="${TRIGGER_BODY%%$'\n'*}" | ||||||||||||||||||||
| TRIGGER_COMMAND="${TRIGGER_COMMAND%$'\r'}" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | ||||||||||||||||||||
| PR_NUMBER="${{ github.event.inputs.pr_number }}" | ||||||||||||||||||||
|
|
@@ -1604,6 +1626,8 @@ jobs: | |||||||||||||||||||
|
|
||||||||||||||||||||
| resolve-pr: | ||||||||||||||||||||
| needs: ['authorize'] | ||||||||||||||||||||
| # The /resolve shape match uses the same fromJSON newline/CR pair as | ||||||||||||||||||||
| # ack-review-request.if, and for the same reason — see the note there. | ||||||||||||||||||||
| if: |- | ||||||||||||||||||||
| !cancelled() && | ||||||||||||||||||||
| github.repository == 'QwenLM/qwen-code' && | ||||||||||||||||||||
|
|
@@ -1616,7 +1640,8 @@ jobs: | |||||||||||||||||||
| github.event.issue.state == 'open' && | ||||||||||||||||||||
| (github.event.comment.body == '@qwen-code /resolve' || | ||||||||||||||||||||
| startsWith(github.event.comment.body, '@qwen-code /resolve ') || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /resolve{0}', '\n')))) | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /resolve{0}', fromJSON('"\n"'))) || | ||||||||||||||||||||
| startsWith(github.event.comment.body, format('@qwen-code /resolve{0}', fromJSON('"\r"'))))) | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| # Pinned to an ephemeral hosted runner. The conflict-resolution agent step | ||||||||||||||||||||
| # runs with `sandbox: true`, which on Linux needs docker or podman to launch | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2207,3 +2207,70 @@ describe('workflow expression length', () => { | |||||||||||||||||||||||||||||
| expect(runReviewStep()).not.toContain('${{'); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| describe('command shape matching', () => { | ||||||||||||||||||||||||||||||
| // A comment may be `@qwen-code /review` followed by a newline and a body. | ||||||||||||||||||||||||||||||
| // The `if`s tried to accept that with format('…{0}', '\n'), but expression | ||||||||||||||||||||||||||||||
| // string literals are NOT escape-processed: that '\n' is a literal | ||||||||||||||||||||||||||||||
| // backslash + n, so the branch matched nothing and every multi-line command | ||||||||||||||||||||||||||||||
| // was silently ignored — no run, no feedback. Measured on a live runner: | ||||||||||||||||||||||||||||||
| // startsWith(<LF body>, format('…{0}', '\n')) => false | ||||||||||||||||||||||||||||||
| // startsWith(<LF body>, format('…{0}', fromJSON('"\n"'))) => true | ||||||||||||||||||||||||||||||
| // startsWith(<CRLF body>, format('…{0}', fromJSON('"\n"'))) => false | ||||||||||||||||||||||||||||||
| // startsWith(<CRLF body>, format('…{0}', fromJSON('"\r"'))) => true | ||||||||||||||||||||||||||||||
| // Hence fromJSON (JSON *is* escape-processed) and both line endings: the | ||||||||||||||||||||||||||||||
| // REST API sends LF, the web UI sends CRLF. | ||||||||||||||||||||||||||||||
| const doc = parse(workflow); | ||||||||||||||||||||||||||||||
| const ifs = Object.entries(doc.jobs) | ||||||||||||||||||||||||||||||
| .filter(([, job]) => typeof job?.if === 'string') | ||||||||||||||||||||||||||||||
| .map(([id, job]) => [id, job.if]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| it('never matches a command shape with a non-escaped literal newline', () => { | ||||||||||||||||||||||||||||||
| const broken = ifs.filter(([, cond]) => /'\\[nr]'/.test(cond)); | ||||||||||||||||||||||||||||||
| expect(broken.map(([id]) => id)).toEqual([]); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| it('accepts both LF and CRLF after the command in every shape match', () => { | ||||||||||||||||||||||||||||||
| // `authorize` deliberately matches only a loose prefix — it is a filter to | ||||||||||||||||||||||||||||||
| // avoid spawning a job per comment, and delegates the exact shape to the | ||||||||||||||||||||||||||||||
| // downstream jobs. Jobs that do the shape match are the ones that use | ||||||||||||||||||||||||||||||
| // format('@qwen-code /<cmd>{0}', …), so key off that. | ||||||||||||||||||||||||||||||
| const withShape = ifs.filter(([, cond]) => | ||||||||||||||||||||||||||||||
| cond.includes("format('@qwen-code /"), | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
Comment on lines
+2238
to
+2240
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Test 2 verifies the LF/CR pair per job with a single Failure scenario: mutation-demonstrated — replacing the CR variant with the LF variant in
Suggested change
中文说明测试 2 对每个 job 用一次 — deepseek-v4-flash via Qwen Code /review (v0.21.7) |
||||||||||||||||||||||||||||||
| expect(withShape.length).toBeGreaterThan(0); | ||||||||||||||||||||||||||||||
| const missing = []; | ||||||||||||||||||||||||||||||
| for (const [id, cond] of withShape) { | ||||||||||||||||||||||||||||||
| for (const cmd of ['review', 'resolve']) { | ||||||||||||||||||||||||||||||
| // Only check commands this job actually matches on. | ||||||||||||||||||||||||||||||
| if (!cond.includes(`format('@qwen-code /${cmd}{0}'`)) continue; | ||||||||||||||||||||||||||||||
| const lf = cond.includes( | ||||||||||||||||||||||||||||||
| `format('@qwen-code /${cmd}{0}', fromJSON('"\\n"'))`, | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| const cr = cond.includes( | ||||||||||||||||||||||||||||||
| `format('@qwen-code /${cmd}{0}', fromJSON('"\\r"'))`, | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| if (!lf || !cr) missing.push(`${id}/${cmd} (LF:${lf} CR:${cr})`); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| expect(missing).toEqual([]); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| it('strips a trailing CR before parsing command tokens', () => { | ||||||||||||||||||||||||||||||
| // Word splitting uses IFS, which has no CR, so a CRLF comment would carry | ||||||||||||||||||||||||||||||
| // `\r` into tokens like `--timeout=300` and fail the numeric check. | ||||||||||||||||||||||||||||||
| // The command is parsed in "Resolve PR context", not in "Run review". | ||||||||||||||||||||||||||||||
| const run = parse(workflow).jobs['review-pr'].steps.find( | ||||||||||||||||||||||||||||||
| (s) => s.id === 'context', | ||||||||||||||||||||||||||||||
| ).run; | ||||||||||||||||||||||||||||||
| const firstLine = run.indexOf( | ||||||||||||||||||||||||||||||
| 'TRIGGER_COMMAND="${TRIGGER_BODY%%$\'\\n\'*}"', | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| const stripCr = run.indexOf( | ||||||||||||||||||||||||||||||
| 'TRIGGER_COMMAND="${TRIGGER_COMMAND%$\'\\r\'}"', | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| expect(firstLine).toBeGreaterThan(-1); | ||||||||||||||||||||||||||||||
| expect(stripCr).toBeGreaterThan(-1); | ||||||||||||||||||||||||||||||
| expect(stripCr).toBeGreaterThan(firstLine); | ||||||||||||||||||||||||||||||
|
Comment on lines
+2272
to
+2274
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Test 3 ("strips a trailing CR before parsing command tokens") only pins the strip after the first-line extraction — it never pins the strip BEFORE the Failure scenario: the bug this PR fixes is specifically that a trailing CR survives into tokens (
Suggested change
中文说明测试 3 的标题声称“在解析命令 token 之前剥离尾部 CR”,但它只断言了剥离发生在取首行之后,从未断言剥离发生在 — deepseek-v4-flash via Qwen Code /review (v0.21.7) |
||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] Nothing pins the
ack-review-request.if↔review-pr.ifshape contract to each other; test 2 verifies each job's LF/CR pair independently, so a one-job shape edit desyncs the pair with the suite green.Failure scenario: the same
/reviewshapes live in two jobs (ack-review-requestandreview-pr) plusresolve-pr— 7 matches, guarded only by "KEEP IN SYNC" comments. A future contributor who adds a trigger branch or edits the shape list in only one of the two jobs leaves the pair desynced while test 2 still passes.ack-review-requestthen posts "Review queued" for a body thatreview-pr.ifno longer matches — the review silently never starts, the exact silent-ignore bug class this PR fixes, with all three new tests green. This PR is itself the edit class: it changed the same shapes in two jobs in one commit.中文说明
/review的形状匹配同时存在于ack-review-request和review-pr两个 job(加上resolve-pr共 7 处),目前只靠 "KEEP IN SYNC" 注释维系。测试 2 只独立检查每个 job 的 LF/CR 对,因此未来只改其中一个 job(新增触发分支或修改形状列表)会让两者失同步而套件依然全绿:ack-review-request对某个 body 发“Review queued”,而review-pr.if已不再匹配——评审静默不启动,正是本 PR 修复的静默忽略类 bug。本 PR 本身就是这类编辑:一个 commit 里改了同一形状在两处 job。建议加断言:两个 job 的显式触发形状集合必须完全一致。— deepseek-v4-flash via Qwen Code /review (v0.21.7)