From 8f5ef70927c944d2e964bee2eea035b4352ac3e0 Mon Sep 17 00:00:00 2001 From: Marc Izquierdo Date: Tue, 20 Oct 2026 19:53:11 +0200 Subject: [PATCH 1/8] Build comment payloads with jq The comment JSON was assembled by string interpolation, with only double quotes stripped. Backslashes made the payload invalid (C:\Users, \d+, a trailing \), so the comment was rejected, and \n or \u0022 were reinterpreted. Stripping quotes also removed them from PR titles, bodies and review text, including the quotes the lightweight comments put around the title. Let jq escape the body instead. Co-Authored-By: Claude Opus 5.5 --- src/teamwork.sh | 9 ++++-- .../cases/closed-merged-lightweight/expected | 2 +- tests/cases/closed-merged/expected | 13 +++++++-- .../closed-unmerged-lightweight/expected | 2 +- tests/cases/closed-unmerged/expected | 13 +++++++-- tests/cases/comment-fails/expected | 2 +- .../deprecated-board-column-closed/expected | 2 +- tests/cases/deprecated-board-column/expected | 2 +- tests/cases/opened-lightweight/expected | 2 +- tests/cases/opened/expected | 29 +++++++++++++++++-- .../review-approved-lightweight/expected | 2 +- tests/cases/review-approved-no-body/expected | 18 ++++++++++-- tests/cases/review-approved/expected | 19 ++++++++++-- tests/cases/stage-not-found/expected | 2 +- tests/cases/tagging-disabled/expected | 2 +- tests/cases/task-links/expected | 16 +++++----- tests/cases/task-not-found/expected | 4 +-- tests/cases/wrong-token/expected | 2 +- 18 files changed, 103 insertions(+), 38 deletions(-) diff --git a/src/teamwork.sh b/src/teamwork.sh index 4439a1df..be7e18d3 100644 --- a/src/teamwork.sh +++ b/src/teamwork.sh @@ -101,12 +101,15 @@ teamwork::add_comment() { local -r body=$1 if [ "$ENV" == "test" ]; then - log::message "Test - Simulate request. Task ID: $TEAMWORK_TASK_ID - Comment: ${body//\"/}" + log::message "Test - Simulate request. Task ID: $TEAMWORK_TASK_ID - Comment: $body" return fi - response=$(teamwork::request POST "/tasks/$TEAMWORK_TASK_ID/comments.json" \ - "{ \"comment\": { \"body\": \"${body//\"/}\", \"notify\": true, \"content-type\": \"text\", \"isprivate\": false } }") + # jq escapes the body (quotes, backslashes, newlines...) into valid JSON + local -r payload=$(jq -n --arg body "$body" \ + '{ comment: { body: $body, notify: true, "content-type": "text", isprivate: false } }') + + response=$(teamwork::request POST "/tasks/$TEAMWORK_TASK_ID/comments.json" "$payload") log::message "$response" } diff --git a/tests/cases/closed-merged-lightweight/expected b/tests/cases/closed-merged-lightweight/expected index 3c02d13c..efc96a65 100644 --- a/tests/cases/closed-merged-lightweight/expected +++ b/tests/cases/closed-merged-lightweight/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) merged by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) merged by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/closed-merged/expected b/tests/cases/closed-merged/expected index 5fb0536e..e7ec2753 100644 --- a/tests/cases/closed-merged/expected +++ b/tests/cases/closed-merged/expected @@ -2,9 +2,16 @@ exit code: 0 GET /projects/api/v1/tasks/1001.json -> 200 -POST /tasks/1001/comments.json -> 400 - invalid JSON: Invalid control character at: line 1 column 25 (char 24) - "{ \"comment\": { \"body\": \"\n**octocat** merged a PR: **Add OAuth login**\n[https://github.com/acme/webapp/pull/42](https://github.com/acme/webapp/pull/42)\n\", \"notify\": true, \"content-type\": \"text\", \"isprivate\": false } }" +POST /tasks/1001/comments.json -> 201 + comment: + body: + | + | **octocat** merged a PR: **Add OAuth login** + | [https://github.com/acme/webapp/pull/42](https://github.com/acme/webapp/pull/42) + | + content-type: "text" + isprivate: false + notify: true PUT /tasks/1001/tags.json -> 200 tags: diff --git a/tests/cases/closed-unmerged-lightweight/expected b/tests/cases/closed-unmerged-lightweight/expected index e4430984..e7628885 100644 --- a/tests/cases/closed-unmerged-lightweight/expected +++ b/tests/cases/closed-unmerged-lightweight/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) closed without merging by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) closed without merging by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/closed-unmerged/expected b/tests/cases/closed-unmerged/expected index ab0f25ce..a6bd6e18 100644 --- a/tests/cases/closed-unmerged/expected +++ b/tests/cases/closed-unmerged/expected @@ -2,9 +2,16 @@ exit code: 0 GET /projects/api/v1/tasks/1001.json -> 200 -POST /tasks/1001/comments.json -> 400 - invalid JSON: Invalid control character at: line 1 column 25 (char 24) - "{ \"comment\": { \"body\": \"\n**octocat** closed a PR without merging: **Add OAuth login**\n[https://github.com/acme/webapp/pull/42](https://github.com/acme/webapp/pull/42)\n\", \"notify\": true, \"content-type\": \"text\", \"isprivate\": false } }" +POST /tasks/1001/comments.json -> 201 + comment: + body: + | + | **octocat** closed a PR without merging: **Add OAuth login** + | [https://github.com/acme/webapp/pull/42](https://github.com/acme/webapp/pull/42) + | + content-type: "text" + isprivate: false + notify: true PUT /tasks/1001/tags.json -> 200 removeProvidedTags: "true" diff --git a/tests/cases/comment-fails/expected b/tests/cases/comment-fails/expected index faeb3aba..de1fd952 100644 --- a/tests/cases/comment-fails/expected +++ b/tests/cases/comment-fails/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1003.json -> 200 POST /tasks/1003/comments.json -> 500 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/deprecated-board-column-closed/expected b/tests/cases/deprecated-board-column-closed/expected index 3d2d9efc..881198eb 100644 --- a/tests/cases/deprecated-board-column-closed/expected +++ b/tests/cases/deprecated-board-column-closed/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) closed without merging by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) closed without merging by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/deprecated-board-column/expected b/tests/cases/deprecated-board-column/expected index 395ab4d1..0e1fefba 100644 --- a/tests/cases/deprecated-board-column/expected +++ b/tests/cases/deprecated-board-column/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/opened-lightweight/expected b/tests/cases/opened-lightweight/expected index 55e3f681..0d73eac7 100644 --- a/tests/cases/opened-lightweight/expected +++ b/tests/cases/opened-lightweight/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Handle null in C:\temp**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Handle \"null\" in C:\\temp\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/opened/expected b/tests/cases/opened/expected index e550bec5..6623d45e 100644 --- a/tests/cases/opened/expected +++ b/tests/cases/opened/expected @@ -2,9 +2,32 @@ exit code: 0 GET /projects/api/v1/tasks/1001.json -> 200 -POST /tasks/1001/comments.json -> 400 - invalid JSON: Invalid control character at: line 1 column 25 (char 24) - "{ \"comment\": { \"body\": \"\n**octocat** opened a PR: **Add OAuth login**\n[https://github.com/acme/webapp/pull/42](https://github.com/acme/webapp/pull/42)\n`main` \u2b05\ufe0f `feature/oauth`\n\n---\n\n#### Summary\r\nAdds OAuth login for http://teamwork:8080/app/tasks/1001\r\n\r\n```sh\r\n#### configure the client secret\r\nexport CONFIG_DIR=C:\\Users\\dev\\config\r\n```\n\n---\n\n\ud83d\udd22 3 commits / \ud83d\udcdd 5 files updated / \u2795 120 additions / \u2796 8 deletions\n\n\", \"notify\": true, \"content-type\": \"text\", \"isprivate\": false } }" +POST /tasks/1001/comments.json -> 201 + comment: + body: + | + | **octocat** opened a PR: **Add "OAuth" login** + | [https://github.com/acme/webapp/pull/42](https://github.com/acme/webapp/pull/42) + | `main` ⬅️ `feature/oauth` + | + | --- + | + | #### Summary␍ + | Adds OAuth login for http://teamwork:8080/app/tasks/1001␍ + | ␍ + | ```sh␍ + | #### configure the "client" secret␍ + | export CONFIG_DIR=C:\Users\dev\config␍ + | ``` + | + | --- + | + | 🔢 3 commits / 📝 5 files updated / ➕ 120 additions / ➖ 8 deletions + | + | + content-type: "text" + isprivate: false + notify: true PUT /tasks/1001/tags.json -> 200 tags: diff --git a/tests/cases/review-approved-lightweight/expected b/tests/cases/review-approved-lightweight/expected index 9d4207f1..3a097566 100644 --- a/tests/cases/review-approved-lightweight/expected +++ b/tests/cases/review-approved-lightweight/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) approved by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) approved by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/review-approved-no-body/expected b/tests/cases/review-approved-no-body/expected index 84ff709f..4624ea25 100644 --- a/tests/cases/review-approved-no-body/expected +++ b/tests/cases/review-approved-no-body/expected @@ -2,9 +2,21 @@ exit code: 0 GET /projects/api/v1/tasks/1001.json -> 200 -POST /tasks/1001/comments.json -> 400 - invalid JSON: Invalid control character at: line 1 column 25 (char 24) - "{ \"comment\": { \"body\": \"\n**octocat** submitted a review to the PR: **Add OAuth login**\n[https://github.com/acme/webapp/pull/42](https://github.com/acme/webapp/pull/42)\n\n---\n\nReview: **approved**\n\n\", \"notify\": true, \"content-type\": \"text\", \"isprivate\": false } }" +POST /tasks/1001/comments.json -> 201 + comment: + body: + | + | **octocat** submitted a review to the PR: **Add OAuth login** + | [https://github.com/acme/webapp/pull/42](https://github.com/acme/webapp/pull/42) + | + | --- + | + | Review: **approved** + | + | + content-type: "text" + isprivate: false + notify: true PUT /tasks/1001/tags.json -> 200 tags: diff --git a/tests/cases/review-approved/expected b/tests/cases/review-approved/expected index f24cf9cb..5c558047 100644 --- a/tests/cases/review-approved/expected +++ b/tests/cases/review-approved/expected @@ -2,9 +2,22 @@ exit code: 0 GET /projects/api/v1/tasks/1001.json -> 200 -POST /tasks/1001/comments.json -> 400 - invalid JSON: Invalid control character at: line 1 column 25 (char 24) - "{ \"comment\": { \"body\": \"\n**octocat** submitted a review to the PR: **Add OAuth login**\n[https://github.com/acme/webapp/pull/42](https://github.com/acme/webapp/pull/42)\n\n---\n\nReview: **approved**\nLooks good!\r\nOne nit: rename `cfg` to config.\n\", \"notify\": true, \"content-type\": \"text\", \"isprivate\": false } }" +POST /tasks/1001/comments.json -> 201 + comment: + body: + | + | **octocat** submitted a review to the PR: **Add OAuth login** + | [https://github.com/acme/webapp/pull/42](https://github.com/acme/webapp/pull/42) + | + | --- + | + | Review: **approved** + | Looks good!␍ + | One nit: rename `cfg` to "config". + | + content-type: "text" + isprivate: false + notify: true PUT /tasks/1001/tags.json -> 200 tags: diff --git a/tests/cases/stage-not-found/expected b/tests/cases/stage-not-found/expected index b1796459..9bcdc992 100644 --- a/tests/cases/stage-not-found/expected +++ b/tests/cases/stage-not-found/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/tagging-disabled/expected b/tests/cases/tagging-disabled/expected index c39de2a7..121253c7 100644 --- a/tests/cases/tagging-disabled/expected +++ b/tests/cases/tagging-disabled/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) merged by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) merged by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/task-links/expected b/tests/cases/task-links/expected index 8bc306ea..10871ef8 100644 --- a/tests/cases/task-links/expected +++ b/tests/cases/task-links/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true @@ -13,7 +13,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true @@ -22,7 +22,7 @@ GET /projects/api/v1/tasks/1002.json -> 200 POST /tasks/1002/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true @@ -31,7 +31,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true @@ -40,7 +40,7 @@ GET /projects/api/v1/tasks/5.json -> 404 POST /tasks/5/comments.json -> 404 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true @@ -49,7 +49,7 @@ GET /projects/api/v1/tasks/2024.json -> 404 POST /tasks/2024/comments.json -> 404 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true @@ -58,7 +58,7 @@ GET /projects/api/v1/tasks/1003.json -> 200 POST /tasks/1003/comments.json -> 500 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true @@ -67,7 +67,7 @@ GET /projects/api/v1/tasks/1003.json -> 200 POST /tasks/1003/comments.json -> 500 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/task-not-found/expected b/tests/cases/task-not-found/expected index 57c82da5..48a71c91 100644 --- a/tests/cases/task-not-found/expected +++ b/tests/cases/task-not-found/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/9999.json -> 404 POST /tasks/9999/comments.json -> 404 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true @@ -13,7 +13,7 @@ GET /projects/api/v1/tasks/1001.json -> 200 POST /tasks/1001/comments.json -> 201 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true diff --git a/tests/cases/wrong-token/expected b/tests/cases/wrong-token/expected index a86eb133..8ee23a7c 100644 --- a/tests/cases/wrong-token/expected +++ b/tests/cases/wrong-token/expected @@ -4,7 +4,7 @@ GET /projects/api/v1/tasks/1001.json -> 401 POST /tasks/1001/comments.json -> 401 comment: - body: "PR [**Add OAuth login**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" content-type: "text" isprivate: false notify: true From 510fa49deddf049205f99252f93e3165f2318996 Mon Sep 17 00:00:00 2001 From: Marc Izquierdo Date: Tue, 20 Oct 2026 19:53:35 +0200 Subject: [PATCH 2/8] Count each linked task once A task linked more than once, e.g. in a Markdown link whose text is also the URL, got one comment, tag and stage move per link. Co-Authored-By: Claude Opus 5.5 --- src/teamwork.sh | 4 +++- tests/cases/task-links/expected | 27 --------------------------- 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/src/teamwork.sh b/src/teamwork.sh index be7e18d3..e6ddf248 100644 --- a/src/teamwork.sh +++ b/src/teamwork.sh @@ -6,7 +6,9 @@ teamwork::get_task_id_from_body() { pat='tasks\/([0-9]{1,})' while [[ $body =~ $pat ]]; do - task_ids+=( "${BASH_REMATCH[1]}" ) + if ! utils::in_array "${BASH_REMATCH[1]}" "${task_ids[*]}"; then + task_ids+=( "${BASH_REMATCH[1]}" ) + fi body=${body#*"${BASH_REMATCH[0]}"} done diff --git a/tests/cases/task-links/expected b/tests/cases/task-links/expected index 10871ef8..97acb8f2 100644 --- a/tests/cases/task-links/expected +++ b/tests/cases/task-links/expected @@ -2,15 +2,6 @@ exit code: 0 GET /projects/api/v1/tasks/1001.json -> 200 -POST /tasks/1001/comments.json -> 201 - comment: - body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" - content-type: "text" - isprivate: false - notify: true - -GET /projects/api/v1/tasks/1001.json -> 200 - POST /tasks/1001/comments.json -> 201 comment: body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" @@ -27,15 +18,6 @@ POST /tasks/1002/comments.json -> 201 isprivate: false notify: true -GET /projects/api/v1/tasks/1001.json -> 200 - -POST /tasks/1001/comments.json -> 201 - comment: - body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" - content-type: "text" - isprivate: false - notify: true - GET /projects/api/v1/tasks/5.json -> 404 POST /tasks/5/comments.json -> 404 @@ -62,12 +44,3 @@ POST /tasks/1003/comments.json -> 500 content-type: "text" isprivate: false notify: true - -GET /projects/api/v1/tasks/1003.json -> 200 - -POST /tasks/1003/comments.json -> 500 - comment: - body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" - content-type: "text" - isprivate: false - notify: true From bd17b95463217f356d5f7a6f765c561a97b8559d Mon Sep 17 00:00:00 2001 From: Marc Izquierdo Date: Tue, 20 Oct 2026 19:53:56 +0200 Subject: [PATCH 3/8] Keep going after a task in an ignored project The IGNORE_PROJECT_IDS check exited the whole run, so tasks linked after one in an ignored project were never processed. Co-Authored-By: Claude Opus 5.5 --- src/main.sh | 2 +- tests/cases/ignored-project/expected | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main.sh b/src/main.sh index 62a3b37f..fcb3bee2 100644 --- a/src/main.sh +++ b/src/main.sh @@ -61,7 +61,7 @@ main() { if utils::in_array "$project_id" "${ignored_project_ids[*]}" then log::message "ignored due to IGNORE_PROJECT_IDS" - exit 0 + continue fi if [ "$event" == "pull_request" ] && [ "$action" == "opened" ]; then diff --git a/tests/cases/ignored-project/expected b/tests/cases/ignored-project/expected index 19f4cbd7..cfd35fab 100644 --- a/tests/cases/ignored-project/expected +++ b/tests/cases/ignored-project/expected @@ -1,3 +1,12 @@ exit code: 0 GET /projects/api/v1/tasks/1001.json -> 200 + +GET /projects/api/v1/tasks/1002.json -> 200 + +POST /tasks/1002/comments.json -> 201 + comment: + body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" + content-type: "text" + isprivate: false + notify: true From d251f1c1d32f5672d85a794c171d2fad6d472133 Mon Sep 17 00:00:00 2001 From: Marc Izquierdo Date: Tue, 20 Oct 2026 19:54:19 +0200 Subject: [PATCH 4/8] Leave code blocks alone when demoting headings The H1-H3 rewrite in the PR body also turned comment lines in fenced code blocks (shell, Python, YAML...) into #### headings. Track fences with awk and only rewrite lines outside them. Co-Authored-By: Claude Opus 5.5 --- src/github.sh | 3 ++- tests/cases/opened/expected | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/github.sh b/src/github.sh index 23547aa7..dd59847d 100644 --- a/src/github.sh +++ b/src/github.sh @@ -13,7 +13,8 @@ github::get_pr_number() { } github::get_pr_body() { - jq --raw-output .pull_request.body "$GITHUB_EVENT_PATH" | sed -r 's/^#{1,3}\ /#### /g' + jq --raw-output .pull_request.body "$GITHUB_EVENT_PATH" |\ + awk '/^[[:space:]]*(```|~~~)/ { fenced = !fenced } !fenced { sub(/^#{1,3} /, "#### ") } { print }' } github::get_head_ref() { diff --git a/tests/cases/opened/expected b/tests/cases/opened/expected index 6623d45e..319ba31d 100644 --- a/tests/cases/opened/expected +++ b/tests/cases/opened/expected @@ -16,7 +16,7 @@ POST /tasks/1001/comments.json -> 201 | Adds OAuth login for http://teamwork:8080/app/tasks/1001␍ | ␍ | ```sh␍ - | #### configure the "client" secret␍ + | # configure the "client" secret␍ | export CONFIG_DIR=C:\Users\dev\config␍ | ``` | From ed5c4f1324dfa158ea46ab4fe8557f1dafc823c1 Mon Sep 17 00:00:00 2001 From: Marc Izquierdo Date: Tue, 20 Oct 2026 19:54:47 +0200 Subject: [PATCH 5/8] Remove unused functions Nothing calls ensure::total_args, github::get_pr_number, github::get_repository_full_name or github::print_all_data. Co-Authored-By: Claude Opus 5.5 --- src/ensure.sh | 10 ---------- src/github.sh | 12 ------------ 2 files changed, 22 deletions(-) diff --git a/src/ensure.sh b/src/ensure.sh index 05f12470..fe4dd489 100644 --- a/src/ensure.sh +++ b/src/ensure.sh @@ -6,13 +6,3 @@ ensure::env_variable_exist() { exit 1 fi } - -ensure::total_args() { - local -r received_args=$(( $# - 1 )) - local -r expected_args=$1 - - if ((received_args != expected_args)); then - echoerr "Illegal number of parameters, $expected_args expected but $received_args found" - exit 1 - fi -} diff --git a/src/github.sh b/src/github.sh index dd59847d..b3932a08 100644 --- a/src/github.sh +++ b/src/github.sh @@ -8,10 +8,6 @@ github::get_action() { jq --raw-output .action "$GITHUB_EVENT_PATH" } -github::get_pr_number() { - jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH" -} - github::get_pr_body() { jq --raw-output .pull_request.body "$GITHUB_EVENT_PATH" |\ awk '/^[[:space:]]*(```|~~~)/ { fenced = !fenced } !fenced { sub(/^#{1,3} /, "#### ") } { print }' @@ -25,10 +21,6 @@ github::get_base_ref() { jq --raw-output .pull_request.base.ref "$GITHUB_EVENT_PATH" } -github::get_repository_full_name() { - jq --raw-output .pull_request.head.repo.full_name "$GITHUB_EVENT_PATH" -} - github::get_pr_url() { jq --raw-output .pull_request.html_url "$GITHUB_EVENT_PATH" } @@ -56,7 +48,3 @@ github::get_review_state() { github::get_review_comment() { jq --raw-output '.review.body // empty' "$GITHUB_EVENT_PATH" } - -github::print_all_data() { - cat "$GITHUB_EVENT_PATH" -} From 38f3395317b28733489b8fc8bfe0826b92d4ab65 Mon Sep 17 00:00:00 2001 From: Marc Izquierdo Date: Tue, 20 Oct 2026 19:55:13 +0200 Subject: [PATCH 6/8] Fix the required environment variable check It called echoerr, which doesn't exist, so an empty variable failed with "command not found" (exit 127), and an unset one hit set -u ("unbound variable") before the check ran. Also check GITHUB_EVENT_NAME, which the action relies on. Co-Authored-By: Claude Opus 5.5 --- src/ensure.sh | 4 ++-- src/main.sh | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ensure.sh b/src/ensure.sh index fe4dd489..3bec16a0 100644 --- a/src/ensure.sh +++ b/src/ensure.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash ensure::env_variable_exist() { - if [[ -z "${!1}" ]]; then - echoerr "The env variable $1 is required." + if [[ -z "${!1:-}" ]]; then + log::error "The env variable $1 is required." exit 1 fi } diff --git a/src/main.sh b/src/main.sh index fcb3bee2..a2051e5a 100644 --- a/src/main.sh +++ b/src/main.sh @@ -12,6 +12,7 @@ main() { # Ensure env vars and args exist ensure::env_variable_exist "GITHUB_REPOSITORY" ensure::env_variable_exist "GITHUB_EVENT_PATH" + ensure::env_variable_exist "GITHUB_EVENT_NAME" export GITHUB_TOKEN="$1" export TEAMWORK_URI="$2" From 5d17ed3e7d3d76b2689331f30887d73404ae2470 Mon Sep 17 00:00:00 2001 From: Marc Izquierdo Date: Tue, 20 Oct 2026 19:55:42 +0200 Subject: [PATCH 7/8] Pick the event handler before looking tasks up Unsupported events looked up every linked task before giving up. Decide on the handler first, so they make no API calls. The next commit relies on this so that failed lookups can't fail runs for events the action ignores. Co-Authored-By: Claude Opus 5.5 --- src/main.sh | 33 +++++++++++++++----------- tests/cases/unsupported-event/expected | 2 -- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/main.sh b/src/main.sh index a2051e5a..bdd93d71 100644 --- a/src/main.sh +++ b/src/main.sh @@ -49,6 +49,24 @@ main() { log::message "Event: $event - Action: $action" + # Pick the handler before touching the API, so unsupported events make no + # requests at all + local handler + if [ "$event" == "pull_request" ] && [ "$action" == "opened" ]; then + handler=teamwork::pull_request_opened + elif [ "$event" == "pull_request" ] && [ "$action" == "closed" ]; then + handler=teamwork::pull_request_closed + elif [ "$event" == "pull_request_review" ] && [ "$action" == "submitted" ]; then + handler=teamwork::pull_request_review_submitted + elif [ "$event" == "pull_request_review" ] && [ "$action" == "dismissed" ]; then + handler=teamwork::pull_request_review_dismissed + elif [ "$ENV" == "test" ]; then # always run pull_request_opened at the very least when in test + handler=teamwork::pull_request_opened + else + log::message "Operation not allowed" + exit 0 + fi + local project_id IFS=',' read -r -a task_ids <<< "$task_ids_str" for task_id in "${task_ids[@]}"; do @@ -65,20 +83,7 @@ main() { continue fi - if [ "$event" == "pull_request" ] && [ "$action" == "opened" ]; then - teamwork::pull_request_opened - elif [ "$event" == "pull_request" ] && [ "$action" == "closed" ]; then - teamwork::pull_request_closed - elif [ "$event" == "pull_request_review" ] && [ "$action" == "submitted" ]; then - teamwork::pull_request_review_submitted - elif [ "$event" == "pull_request_review" ] && [ "$action" == "dismissed" ]; then - teamwork::pull_request_review_dismissed - elif [ "$ENV" == "test" ]; then # always run pull_request_opened at the very least when in test - teamwork::pull_request_opened - else - log::message "Operation not allowed" - exit 0 - fi + "$handler" done exit $? diff --git a/tests/cases/unsupported-event/expected b/tests/cases/unsupported-event/expected index 19f4cbd7..01fe490f 100644 --- a/tests/cases/unsupported-event/expected +++ b/tests/cases/unsupported-event/expected @@ -1,3 +1 @@ exit code: 0 - -GET /projects/api/v1/tasks/1001.json -> 200 From 4312cadeeca20c49bf4e1ecb4c7805237d1c35b8 Mon Sep 17 00:00:00 2001 From: Marc Izquierdo Date: Tue, 20 Oct 2026 19:56:48 +0200 Subject: [PATCH 8/8] Fail the run when a Teamwork request fails curl ran without --fail, so HTTP errors (a revoked token, a task the token can't see, a Teamwork outage) were only logged and the run still passed. A failed task lookup also carried on with "null" as the project id. Network errors, on the other hand, aborted the run halfway, and a hung API held the runner until the job timed out. Pass --fail-with-body and --max-time 30, log what failed, carry on with the other steps and tasks, then exit 1 if anything failed. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +-- src/main.sh | 14 ++++++-- src/teamwork.sh | 54 ++++++++++++++++++++--------- tests/cases/comment-fails/expected | 7 +++- tests/cases/task-links/expected | 25 ++++++------- tests/cases/task-not-found/expected | 14 ++++---- tests/cases/wrong-token/expected | 14 ++++---- 7 files changed, 80 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 5d8344ce..e124556e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Create the next environment vars in your repository: * `TEAMWORK_URI`: The URL of your installation (e.g.: https://yourcompany.teamwork.com) * `TEAMWORK_API_TOKEN`: The API token to authenticate the workflow. Follow [this guide](https://support.teamwork.com/projects/using-teamwork/locating-your-api-key) to find your URL and API key. -**Please Note:** The Teamwork account associated with this API key is the account which these comments will be created under. If this user does not have permission to access the project, this action will be ignored. +**Please Note:** The Teamwork account associated with this API key is the account which these comments will be created under. If this user does not have permission to access a task's project, that task will be skipped and the workflow run will be marked as failed. `GITHUB_TOKEN` doesn't need to be setup in the repository, this var is always available during the workflows execution. @@ -59,7 +59,7 @@ jobs: ## Usage When creating a new PR, write in the description of the PR the URL of the task. The action will automatically add a comment in the task. Optionally, you may choose for a lightweight comment that will only include the PR title, URL and the user that performed the action, to enable this option set the `LIGHTWEIGHT_COMMENT` input to `true`. -Please note, the comment will be created in Teamwork under the account you have attached to this action. If the API key of the user you are using does not have permissions to access certain projects, the comment will not be created. +Please note, the comment will be created in Teamwork under the account you have attached to this action. If the API key of the user you are using does not have permissions to access certain projects, the comment will not be created and the workflow run will report an error. ![GitHub pr comment](./.github/assets/github_pr_comment.png) diff --git a/src/main.sh b/src/main.sh index bdd93d71..f7312623 100644 --- a/src/main.sh +++ b/src/main.sh @@ -67,13 +67,18 @@ main() { exit 0 fi + TEAMWORK_FAILED_REQUESTS=0 + local project_id IFS=',' read -r -a task_ids <<< "$task_ids_str" for task_id in "${task_ids[@]}"; do log::message "Task found with the id: $task_id" export TEAMWORK_TASK_ID=$task_id - project_id="$(teamwork::get_project_id_from_task "$task_id")" + if ! project_id="$(teamwork::get_project_id_from_task "$task_id")" || [[ ! $project_id =~ ^[0-9]+$ ]]; then + teamwork::request_failed "Failed to get the project of task $task_id, check the task exists and the API token's user can access it" + continue + fi export TEAMWORK_PROJECT_ID=$project_id ignored_project_ids=("${IGNORE_PROJECT_IDS:-}") @@ -86,5 +91,10 @@ main() { "$handler" done - exit $? + if ((TEAMWORK_FAILED_REQUESTS > 0)); then + log::error "$TEAMWORK_FAILED_REQUESTS Teamwork request(s) failed, see the errors above" + exit 1 + fi + + exit 0 } diff --git a/src/teamwork.sh b/src/teamwork.sh index e6ddf248..b7ff7302 100644 --- a/src/teamwork.sh +++ b/src/teamwork.sh @@ -18,13 +18,14 @@ teamwork::get_task_id_from_body() { echo "$task_ids_str" } -# Calls the Teamwork API and prints the response body. +# Calls the Teamwork API and prints the response body. Fails on network errors, +# timeouts and HTTP error statuses, printing the error body in the latter case. teamwork::request() { local -r method=$1 local -r path=$2 local -r payload=${3:-} - local args=(-sS -X "$method" -u "$TEAMWORK_API_TOKEN"':') + local args=(-sS --fail-with-body --max-time 30 -X "$method" -u "$TEAMWORK_API_TOKEN"':') if [ -n "$payload" ]; then args+=(-H 'Content-Type: application/json; charset=utf-8' -d "$payload") fi @@ -32,6 +33,13 @@ teamwork::request() { curl "${args[@]}" "$TEAMWORK_URI$path" } +# Logs a failed request and records it, so the run fails once every task has +# been processed. +teamwork::request_failed() { + log::error "$1" + TEAMWORK_FAILED_REQUESTS=$((TEAMWORK_FAILED_REQUESTS + 1)) +} + teamwork::get_project_id_from_task() { local -r task_id=$1 @@ -40,17 +48,15 @@ teamwork::get_project_id_from_task() { return fi - response=$( - teamwork::request GET "/projects/api/v1/tasks/$task_id.json" |\ - jq -r '.["todo-item"]["project-id"]' - ) - echo "$response" + teamwork::request GET "/projects/api/v1/tasks/$task_id.json" |\ + jq -r '.["todo-item"]["project-id"]' } # Resolves a workflow stage name to its " " pair within the # task's project. Matching is a case-sensitive substring match against the stage # name (mirroring the legacy board-column behaviour), and the first match wins. -# Echoes nothing when no stage name is provided or no stage matches. +# Echoes nothing when no stage name is provided or no stage matches, and fails +# when the stages can't be fetched. teamwork::get_workflow_stage() { local -r stage_name=$1 @@ -79,7 +85,11 @@ teamwork::move_task_to_stage() { return fi - local -r stage=$(teamwork::get_workflow_stage "$stage_name") + local stage + if ! stage=$(teamwork::get_workflow_stage "$stage_name"); then + teamwork::request_failed "Failed to fetch the workflow stages of project $TEAMWORK_PROJECT_ID" + return + fi if [ -z "$stage" ]; then log::message "Failed to find a matching workflow stage for '$stage_name'" return @@ -93,8 +103,11 @@ teamwork::move_task_to_stage() { return fi - response=$(teamwork::request PATCH "/projects/api/v3/tasks/$TEAMWORK_TASK_ID/workflows/$workflow_id.json" \ - "{ \"stageId\": $stage_id, \"positionAfterTask\": -1 }") + if ! response=$(teamwork::request PATCH "/projects/api/v3/tasks/$TEAMWORK_TASK_ID/workflows/$workflow_id.json" \ + "{ \"stageId\": $stage_id, \"positionAfterTask\": -1 }"); then + teamwork::request_failed "Failed to move task $TEAMWORK_TASK_ID to the '$stage_name' workflow stage: $response" + return + fi log::message "$response" } @@ -111,7 +124,10 @@ teamwork::add_comment() { local -r payload=$(jq -n --arg body "$body" \ '{ comment: { body: $body, notify: true, "content-type": "text", isprivate: false } }') - response=$(teamwork::request POST "/tasks/$TEAMWORK_TASK_ID/comments.json" "$payload") + if ! response=$(teamwork::request POST "/tasks/$TEAMWORK_TASK_ID/comments.json" "$payload"); then + teamwork::request_failed "Failed to add a comment to task $TEAMWORK_TASK_ID: $response" + return + fi log::message "$response" } @@ -125,8 +141,11 @@ teamwork::add_tag() { fi if [ "$AUTOMATIC_TAGGING" == true ]; then - response=$(teamwork::request PUT "/tasks/$TEAMWORK_TASK_ID/tags.json" \ - "{ \"tags\": { \"content\": \"${tag_name//\"/}\" } }") + if ! response=$(teamwork::request PUT "/tasks/$TEAMWORK_TASK_ID/tags.json" \ + "{ \"tags\": { \"content\": \"${tag_name//\"/}\" } }"); then + teamwork::request_failed "Failed to add the '$tag_name' tag to task $TEAMWORK_TASK_ID: $response" + return + fi log::message "$response" fi @@ -141,8 +160,11 @@ teamwork::remove_tag() { fi if [ "$AUTOMATIC_TAGGING" == true ]; then - response=$(teamwork::request PUT "/tasks/$TEAMWORK_TASK_ID/tags.json" \ - "{ \"tags\": { \"content\": \"${tag_name//\"/}\" },\"removeProvidedTags\":\"true\" }") + if ! response=$(teamwork::request PUT "/tasks/$TEAMWORK_TASK_ID/tags.json" \ + "{ \"tags\": { \"content\": \"${tag_name//\"/}\" },\"removeProvidedTags\":\"true\" }"); then + teamwork::request_failed "Failed to remove the '$tag_name' tag from task $TEAMWORK_TASK_ID: $response" + return + fi log::message "$response" fi diff --git a/tests/cases/comment-fails/expected b/tests/cases/comment-fails/expected index de1fd952..f181ae8c 100644 --- a/tests/cases/comment-fails/expected +++ b/tests/cases/comment-fails/expected @@ -1,4 +1,9 @@ -exit code: 0 +exit code: 1 + +stderr: + curl: (22) The requested URL returned error: 500 + Failed to add a comment to task 1003: {"MESSAGE": "Internal server error"} + 1 Teamwork request(s) failed, see the errors above GET /projects/api/v1/tasks/1003.json -> 200 diff --git a/tests/cases/task-links/expected b/tests/cases/task-links/expected index 97acb8f2..148957e2 100644 --- a/tests/cases/task-links/expected +++ b/tests/cases/task-links/expected @@ -1,4 +1,13 @@ -exit code: 0 +exit code: 1 + +stderr: + curl: (22) The requested URL returned error: 404 + Failed to get the project of task 5, check the task exists and the API token's user can access it + curl: (22) The requested URL returned error: 404 + Failed to get the project of task 2024, check the task exists and the API token's user can access it + curl: (22) The requested URL returned error: 500 + Failed to add a comment to task 1003: {"MESSAGE": "Internal server error"} + 3 Teamwork request(s) failed, see the errors above GET /projects/api/v1/tasks/1001.json -> 200 @@ -20,22 +29,8 @@ POST /tasks/1002/comments.json -> 201 GET /projects/api/v1/tasks/5.json -> 404 -POST /tasks/5/comments.json -> 404 - comment: - body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" - content-type: "text" - isprivate: false - notify: true - GET /projects/api/v1/tasks/2024.json -> 404 -POST /tasks/2024/comments.json -> 404 - comment: - body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" - content-type: "text" - isprivate: false - notify: true - GET /projects/api/v1/tasks/1003.json -> 200 POST /tasks/1003/comments.json -> 500 diff --git a/tests/cases/task-not-found/expected b/tests/cases/task-not-found/expected index 48a71c91..c4a6fec0 100644 --- a/tests/cases/task-not-found/expected +++ b/tests/cases/task-not-found/expected @@ -1,13 +1,11 @@ -exit code: 0 +exit code: 1 -GET /projects/api/v1/tasks/9999.json -> 404 +stderr: + curl: (22) The requested URL returned error: 404 + Failed to get the project of task 9999, check the task exists and the API token's user can access it + 1 Teamwork request(s) failed, see the errors above -POST /tasks/9999/comments.json -> 404 - comment: - body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" - content-type: "text" - isprivate: false - notify: true +GET /projects/api/v1/tasks/9999.json -> 404 GET /projects/api/v1/tasks/1001.json -> 200 diff --git a/tests/cases/wrong-token/expected b/tests/cases/wrong-token/expected index 8ee23a7c..23f51418 100644 --- a/tests/cases/wrong-token/expected +++ b/tests/cases/wrong-token/expected @@ -1,10 +1,8 @@ -exit code: 0 +exit code: 1 -GET /projects/api/v1/tasks/1001.json -> 401 +stderr: + curl: (22) The requested URL returned error: 401 + Failed to get the project of task 1001, check the task exists and the API token's user can access it + 1 Teamwork request(s) failed, see the errors above -POST /tasks/1001/comments.json -> 401 - comment: - body: "PR [**\"Add OAuth login\"**](https://github.com/acme/webapp/pull/42) opened by **octocat**" - content-type: "text" - isprivate: false - notify: true +GET /projects/api/v1/tasks/1001.json -> 401