What changed, and why it matters
This commit adds a GitHub Actions bot that automatically moves closed pull requests to the correct column on a project board. It is purely a workflow automation change for project management and has nothing to do with the Trezor firmware code, device security, or user funds.
No security action needed. This is a normal CI/project-management automation change.
Security signals we found
No strong security signals were identified.
Evidence from the diff
A new workflow file .github/workflows/bot-needs-qa.yml is introduced. It triggers on pull_request_target: closed, generates a GitHub App token, and uses GitHub GraphQL to set a project board status field to either ‘Done (no QA)’ or ‘Needs QA’ based on whether the PR was merged and whether it has a ‘no-QA’ label. The workflow requests only repository-projects: write plus read permissions, uses pinned action versions, and runs only in the trezor/trezor-firmware repository.
Changed components
GitHub repository project board automationInspect captured patch +126 / −0
diff --git a/.github/workflows/bot-needs-qa.yml b/.github/workflows/bot-needs-qa.yml
new file mode 100644
index 00000000..161cb444
--- /dev/null
+++ b/.github/workflows/bot-needs-qa.yml
@@ -0,0 +1,126 @@
+name: "[Bot] set project status on PR close"
+
+on:
+ pull_request_target:
+ types: [closed]
+
+permissions:
+ contents: read
+ pull-requests: read
+ repository-projects: write
+
+jobs:
+ set-project-status:
+ # run only in 'trezor/trezor-firmware' repository
+ if: github.repository == 'trezor/trezor-firmware'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate GitHub App token
+ id: trezor-bot-token
+ uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # actions/create-github-app-token@v2.2.1
+ with:
+ app-id: ${{ secrets.TREZOR_BOT_APP_ID }}
+ private-key: ${{ secrets.TREZOR_BOT_PRIVATE_KEY }}
+
+ - name: Set project status based on merge state and no-QA label
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # actions/github-script@v8.0.0
+ env:
+ NOQA_LABEL: no-QA
+ PROJECT_ID: PVT_kwDOAD9FD84ABGAM
+ STATUS_FIELD_ID: PVTSSF_lADOAD9FD84ABGAMzgAo8W8
+ NEEDS_QA_OPTION_ID: 5b18ba23
+ DONE_NOQA_OPTION_ID: 0690ad34
+ with:
+ github-token: ${{ steps.trezor-bot-token.outputs.token }}
+ script: |
+ const {
+ NOQA_LABEL,
+ PROJECT_ID,
+ STATUS_FIELD_ID,
+ NEEDS_QA_OPTION_ID,
+ DONE_NOQA_OPTION_ID,
+ } = process.env;
+
+ const pr = context.payload.pull_request;
+ const isMerged = pr.merged;
+
+ // closed without merge → always "Done (no QA)"
+ // merged + no-QA label → "Done (no QA)"
+ // merged without no-QA → "Needs QA"
+ let targetOptionId;
+ let targetName;
+
+ if (!isMerged) {
+ targetOptionId = DONE_NOQA_OPTION_ID;
+ targetName = "Done (no QA)";
+ core.info("PR closed without merge — targeting 'Done (no QA)'.");
+ } else {
+ const hasNoQA = pr.labels.some(l => l.name === NOQA_LABEL);
+ targetOptionId = hasNoQA ? DONE_NOQA_OPTION_ID : NEEDS_QA_OPTION_ID;
+ targetName = hasNoQA ? "Done (no QA)" : "Needs QA";
+ }
+
+ // --- find the project item linked to this PR ---
+ const { node } = (await github.graphql(
+ `query ($contentId: ID!) {
+ node(id: $contentId) {
+ ... on PullRequest {
+ projectItems(first: 5) {
+ nodes {
+ id
+ project { id }
+ fieldValues(first: 20) {
+ nodes {
+ ... on ProjectV2ItemFieldSingleSelectValue {
+ field { ... on ProjectV2SingleSelectField { id } }
+ optionId
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }`,
+ { contentId: pr.node_id },
+ ));
+
+ const projectItem = node.projectItems.nodes.find(
+ n => n.project.id === PROJECT_ID,
+ );
+
+ if (!projectItem) {
+ core.info("PR is not tracked in the Firmware project — skipping.");
+ return;
+ }
+
+ const currentStatus = projectItem.fieldValues.nodes.find(
+ n => n.field?.id === STATUS_FIELD_ID,
+ )?.optionId;
+
+ if (currentStatus === targetOptionId) {
+ core.info(`Status is already '${targetName}' — skipping.`);
+ return;
+ }
+
+ // --- update the status field ---
+ await github.graphql(
+ `mutation ($project: ID!, $item: ID!, $field: ID!, $value: String!) {
+ updateProjectV2ItemFieldValue(input: {
+ projectId: $project
+ itemId: $item
+ fieldId: $field
+ value: { singleSelectOptionId: $value }
+ }) {
+ projectV2Item { id }
+ }
+ }`,
+ {
+ project: PROJECT_ID,
+ item: projectItem.id,
+ field: STATUS_FIELD_ID,
+ value: targetOptionId,
+ },
+ );
+
+ core.info(`Status set to '${targetName}' for PR #${pr.number}`);
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.