From 752571b3637e6ff4202a954b990720f84b1459c5 Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Sun, 19 Oct 2025 17:39:31 -0500 Subject: [PATCH 01/11] Implement automated PR labeling with security-focused split workflows --- .github/labeler.yml | 5 + .github/scripts/apply-labels.js | 72 ++++++++++++ .github/scripts/apply-labels.test.js | 131 +++++++++++++++++++++ .github/scripts/post-comment.js | 33 ++++++ .github/scripts/post-comment.test.js | 106 +++++++++++++++++ .github/workflows/pr-label-analysis.yml | 93 +++++++++++++++ .github/workflows/pr-label-apply.yml | 144 ++++++++++++++++++++++++ .github/workflows/test-scripts.yml | 55 +++++++++ 8 files changed, 639 insertions(+) create mode 100644 .github/labeler.yml create mode 100644 .github/scripts/apply-labels.js create mode 100644 .github/scripts/apply-labels.test.js create mode 100644 .github/scripts/post-comment.js create mode 100644 .github/scripts/post-comment.test.js create mode 100644 .github/workflows/pr-label-analysis.yml create mode 100644 .github/workflows/pr-label-apply.yml create mode 100644 .github/workflows/test-scripts.yml diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..b594358b --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,5 @@ +cli: + - changed-files: + - any-glob-to-any-file: + - 'Sources/CLI/**/*' + - 'Sources/ContainerCommands/**/*' \ No newline at end of file diff --git a/.github/scripts/apply-labels.js b/.github/scripts/apply-labels.js new file mode 100644 index 00000000..abd3d864 --- /dev/null +++ b/.github/scripts/apply-labels.js @@ -0,0 +1,72 @@ +/** + + * @param {Object} github + * @param {Object} context + * @param {Object} core + * @param {number} prNumber + * @param {string} labelsString + */ +async function applyLabels(github, context, core, prNumber, labelsString) { + if (!labelsString) { + console.log('No labels to apply'); + return { success: false, reason: 'no-labels' }; + } + + const labels = labelsString.split(',') + .map(l => l.trim()) + .filter(l => l !== ''); + + if (labels.length === 0) { + console.log('No labels to apply after filtering'); + return { success: false, reason: 'empty-after-filter' }; + } + + console.log(`Applying labels to PR #${prNumber}: ${labels.join(', ')}`); + + try { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + console.log(`PR #${prNumber} current labels: ${pr.labels.map(l => l.name).join(', ')}`); + + const currentLabels = pr.labels.map(l => l.name); + const newLabels = labels.filter(l => !currentLabels.includes(l)); + + if (newLabels.length === 0) { + console.log('All labels are already applied to the PR'); + return { success: false, reason: 'already-applied' }; + } + + console.log(`Adding new labels: ${newLabels.join(', ')}`); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: newLabels + }); + + console.log('โœ… Labels applied successfully!'); + + return { + success: true, + appliedLabels: newLabels, + shouldComment: true + }; + + } catch (error) { + console.error('โŒ Error applying labels:', error.message); + + if (error.status === 404) { + console.log('PR not found - it may have been deleted or closed'); + return { success: false, reason: 'pr-not-found' }; + } + + throw error; + } + } + + module.exports = applyLabels; \ No newline at end of file diff --git a/.github/scripts/apply-labels.test.js b/.github/scripts/apply-labels.test.js new file mode 100644 index 00000000..458d65a5 --- /dev/null +++ b/.github/scripts/apply-labels.test.js @@ -0,0 +1,131 @@ + + +const applyLabels = require('./apply-labels'); + +const createMockGitHub = (prLabels = [], shouldFail = false) => ({ + rest: { + pulls: { + get: async ({ pull_number }) => { + if (shouldFail) { + const error = new Error('Not Found'); + error.status = 404; + throw error; + } + return { + data: { + number: pull_number, + labels: prLabels.map(name => ({ name })) + } + }; + } + }, + issues: { + addLabels: async ({ issue_number, labels }) => { + console.log(`Mock: Adding labels ${labels.join(', ')} to PR #${issue_number}`); + return { data: {} }; + } + } + } +}); + +const mockContext = { + repo: { + owner: 'apple', + repo: 'container' + } +}; + +const mockCore = { + setOutput: (name, value) => { + console.log(`Mock Core Output: ${name} = ${value}`); + } +}; + +async function runTests() { + console.log('๐Ÿงช Running tests for apply-labels.js\n'); + + console.log('Test 1: Apply new labels to PR with no existing labels'); + const result1 = await applyLabels( + createMockGitHub([]), + mockContext, + mockCore, + 123, + 'cli,documentation' + ); + console.assert(result1.success === true, 'Should succeed'); + console.assert(result1.appliedLabels.length === 2, 'Should apply 2 labels'); + console.log('โœ… Test 1 passed\n'); + + console.log('Test 2: Skip labels that are already applied'); + const result2 = await applyLabels( + createMockGitHub(['cli', 'documentation']), + mockContext, + mockCore, + 123, + 'cli,documentation' + ); + console.assert(result2.success === false, 'Should return false'); + console.assert(result2.reason === 'already-applied', 'Reason should be already-applied'); + console.log('โœ… Test 2 passed\n'); + + console.log('Test 3: Apply only new labels when some already exist'); + const result3 = await applyLabels( + createMockGitHub(['cli']), + mockContext, + mockCore, + 123, + 'cli,documentation,tests' + ); + console.assert(result3.success === true, 'Should succeed'); + console.assert(result3.appliedLabels.length === 2, 'Should apply 2 new labels'); + console.assert(result3.appliedLabels.includes('documentation'), 'Should include documentation'); + console.assert(result3.appliedLabels.includes('tests'), 'Should include tests'); + console.log('โœ… Test 3 passed\n'); + + console.log('Test 4: Handle empty label string'); + const result4 = await applyLabels( + createMockGitHub([]), + mockContext, + mockCore, + 123, + '' + ); + console.assert(result4.success === false, 'Should return false'); + console.assert(result4.reason === 'no-labels', 'Reason should be no-labels'); + console.log('โœ… Test 4 passed\n'); + + console.log('Test 5: Handle whitespace and empty values in label string'); + const result5 = await applyLabels( + createMockGitHub([]), + mockContext, + mockCore, + 123, + 'cli, , documentation, ' + ); + console.assert(result5.success === true, 'Should succeed'); + console.assert(result5.appliedLabels.length === 2, 'Should apply 2 labels after filtering'); + console.log('โœ… Test 5 passed\n'); + + console.log('Test 6: Handle PR not found gracefully'); + const result6 = await applyLabels( + createMockGitHub([], true), + mockContext, + mockCore, + 999, + 'cli' + ); + console.assert(result6.success === false, 'Should return false'); + console.assert(result6.reason === 'pr-not-found', 'Reason should be pr-not-found'); + console.log('โœ… Test 6 passed\n'); + + console.log('๐ŸŽ‰ All tests passed!'); +} + +if (require.main === module) { + runTests().catch(error => { + console.error('โŒ Test failed:', error); + process.exit(1); + }); +} + +module.exports = runTests; \ No newline at end of file diff --git a/.github/scripts/post-comment.js b/.github/scripts/post-comment.js new file mode 100644 index 00000000..bd9783f1 --- /dev/null +++ b/.github/scripts/post-comment.js @@ -0,0 +1,33 @@ +/** + * @param {Object} github + * @param {Object} context + * @param {number} prNumber + * @param {Array} appliedLabels + */ +async function postComment(github, context, prNumber, appliedLabels) { + if (!appliedLabels || appliedLabels.length === 0) { + console.log('No labels to comment about'); + return { success: false }; + } + + const labelBadges = appliedLabels.map(l => `\`${l}\``).join(', '); + const comment = `๐Ÿท๏ธ **Auto-labeler** has applied the following labels: ${labelBadges}`; + + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: comment + }); + + console.log('โœ… Comment posted successfully'); + return { success: true }; + + } catch (error) { + console.error('Failed to post comment:', error.message); + return { success: false, error: error.message }; + } + } + + module.exports = postComment; \ No newline at end of file diff --git a/.github/scripts/post-comment.test.js b/.github/scripts/post-comment.test.js new file mode 100644 index 00000000..1c79f19a --- /dev/null +++ b/.github/scripts/post-comment.test.js @@ -0,0 +1,106 @@ +const postComment = require('./post-comment'); + +const createMockGitHub = (shouldFail = false) => ({ + rest: { + issues: { + createComment: async ({ issue_number, body }) => { + if (shouldFail) { + throw new Error('API Error: Unable to post comment'); + } + console.log(`Mock: Posted comment on PR #${issue_number}`); + console.log(`Comment body: ${body}`); + return { data: {} }; + } + } + } +}); + +const mockContext = { + repo: { + owner: 'apple', + repo: 'container' + } +}; + +async function runTests() { + console.log('๐Ÿงช Running tests for post-comment.js\n'); + + console.log('Test 1: Post comment with single label'); + const result1 = await postComment( + createMockGitHub(), + mockContext, + 123, + ['cli'] + ); + console.assert(result1.success === true, 'Should succeed'); + console.log('โœ… Test 1 passed\n'); + + console.log('Test 2: Post comment with multiple labels'); + const result2 = await postComment( + createMockGitHub(), + mockContext, + 123, + ['cli', 'documentation', 'tests'] + ); + console.assert(result2.success === true, 'Should succeed'); + console.log('โœ… Test 2 passed\n'); + + console.log('Test 3: Handle empty labels array'); + const result3 = await postComment( + createMockGitHub(), + mockContext, + 123, + [] + ); + console.assert(result3.success === false, 'Should return false for empty array'); + console.log('โœ… Test 3 passed\n'); + + console.log('Test 4: Handle null labels'); + const result4 = await postComment( + createMockGitHub(), + mockContext, + 123, + null + ); + console.assert(result4.success === false, 'Should return false for null'); + console.log('โœ… Test 4 passed\n'); + + console.log('Test 5: Handle API failure gracefully'); + const result5 = await postComment( + createMockGitHub(true), + mockContext, + 123, + ['cli'] + ); + console.assert(result5.success === false, 'Should return false on failure'); + console.assert(result5.error !== undefined, 'Should include error message'); + console.log('โœ… Test 5 passed\n'); + + console.log('Test 6: Verify comment format is correct'); + const mockGitHubWithVerification = { + rest: { + issues: { + createComment: async ({ body }) => { + console.assert(body.includes('๐Ÿท๏ธ'), 'Should include emoji'); + console.assert(body.includes('Auto-labeler'), 'Should mention auto-labeler'); + console.assert(body.includes('`cli`'), 'Should format labels as code'); + console.log('Comment format verified'); + return { data: {} }; + } + } + } + }; + await postComment(mockGitHubWithVerification, mockContext, 123, ['cli']); + console.log('โœ… Test 6 passed\n'); + + console.log('๐ŸŽ‰ All tests passed!'); +} + +if (require.main === module) { + runTests().catch(error => { + console.error('โŒ Test failed:', error); + process.exit(1); + }); +} + +module.exports = runTests; \ No newline at end of file diff --git a/.github/workflows/pr-label-analysis.yml b/.github/workflows/pr-label-analysis.yml new file mode 100644 index 00000000..581c3505 --- /dev/null +++ b/.github/workflows/pr-label-analysis.yml @@ -0,0 +1,93 @@ +# Non-privileged workflow that analyzes PR content +# This workflow runs with minimal permissions and processes untrusted code safely +# Following OpenSSF best practices: https://openssf.org/blog/2024/08/12/mitigating-attack-vectors-in-github-workflows/ + +name: PR Label Analysis + +on: + pull_request: + types: [opened] + + +permissions: + contents: read + +jobs: + analyze: + name: Analyze PR for labeling + runs-on: ubuntu-latest + + steps: + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Checkout labeler config from main + uses: actions/checkout@v4 + with: + ref: main + sparse-checkout: | + .github/labeler.yml + sparse-checkout-cone-mode: false + path: trusted-config + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v44 + with: + files_yaml: | + cli: + - 'Sources/CLI/**' + - 'Sources/ContainerCommands/**' + + - name: Build label list + id: build-labels + run: | + LABELS="" + + if [[ "${{ steps.changed-files.outputs.cli_any_changed }}" == "true" ]]; then + LABELS="${LABELS}cli," + fi + + if [[ "${{ steps.changed-files.outputs.builder_any_changed }}" == "true" ]]; then + LABELS="${LABELS}area:builder," + fi + + if [[ "${{ steps.changed-files.outputs.documentation_any_changed }}" == "true" ]]; then + LABELS="${LABELS}documentation," + fi + + if [[ "${{ steps.changed-files.outputs.tests_any_changed }}" == "true" ]]; then + LABELS="${LABELS}tests," + fi + + if [[ "${{ steps.changed-files.outputs.ci_any_changed }}" == "true" ]]; then + LABELS="${LABELS}ci," + fi + + LABELS="${LABELS%,}" + + echo "labels=${LABELS}" >> $GITHUB_OUTPUT + echo "PR #${{ github.event.pull_request.number }} should have labels: ${LABELS}" + + - name: Save PR metadata + run: | + mkdir -p ./pr-metadata + echo "${{ github.event.pull_request.number }}" > ./pr-metadata/pr-number.txt + echo "${{ steps.build-labels.outputs.labels }}" > ./pr-metadata/labels.txt + echo "${{ github.event.pull_request.head.sha }}" > ./pr-metadata/pr-sha.txt + + echo "=== PR Metadata ===" + cat ./pr-metadata/pr-number.txt + cat ./pr-metadata/labels.txt + cat ./pr-metadata/pr-sha.txt + + - name: Upload PR metadata as artifact + uses: actions/upload-artifact@v4 + with: + name: pr-metadata-${{ github.event.pull_request.number }} + path: pr-metadata/ + retention-days: 1 \ No newline at end of file diff --git a/.github/workflows/pr-label-apply.yml b/.github/workflows/pr-label-apply.yml new file mode 100644 index 00000000..6616c5d7 --- /dev/null +++ b/.github/workflows/pr-label-apply.yml @@ -0,0 +1,144 @@ +# Privileged workflow that applies labels to PRs +# This workflow runs with write permissions but does NOT checkout or run untrusted code +# It only processes the PR number and labels from the artifact created by pr-label-analysis.yml +# Following OpenSSF best practices: https://openssf.org/blog/2024/08/12/mitigating-attack-vectors-in-github-workflows/ + +name: PR Label Apply + +on: + workflow_run: + workflows: ["PR Label Analysis"] + types: + - completed + + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + apply-labels: + name: Apply labels to PR + runs-on: ubuntu-latest + + if: ${{ github.event.workflow_run.conclusion == 'success' }} + + steps: + + - name: Checkout scripts from main + uses: actions/checkout@v4 + with: + ref: main + sparse-checkout: | + .github/scripts/ + sparse-checkout-cone-mode: false + + - name: Download PR metadata artifact + uses: actions/download-artifact@v4 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + pattern: pr-metadata-* + merge-multiple: false + continue-on-error: true + id: download-artifact + + - name: Check if artifact was downloaded + id: check-artifact + run: | + METADATA_DIR=$(find . -type d -name "pr-metadata-*" 2>/dev/null | head -n 1) + + if [ -z "$METADATA_DIR" ]; then + echo "No PR metadata artifact found. This might be expected if the analysis workflow had no labels to apply." + echo "artifact-exists=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "artifact-exists=true" >> $GITHUB_OUTPUT + echo "metadata-dir=${METADATA_DIR}" >> $GITHUB_OUTPUT + + - name: Read PR metadata + if: steps.check-artifact.outputs.artifact-exists == 'true' + id: pr-metadata + run: | + METADATA_DIR="${{ steps.check-artifact.outputs.metadata-dir }}" + + if [ ! -f "${METADATA_DIR}/pr-number.txt" ]; then + echo "Error: pr-number.txt not found in artifact" + exit 1 + fi + + PR_NUMBER=$(cat "${METADATA_DIR}/pr-number.txt") + LABELS=$(cat "${METADATA_DIR}/labels.txt" || echo "") + PR_SHA=$(cat "${METADATA_DIR}/pr-sha.txt" || echo "") + + echo "pr-number=${PR_NUMBER}" >> $GITHUB_OUTPUT + echo "labels=${LABELS}" >> $GITHUB_OUTPUT + echo "pr-sha=${PR_SHA}" >> $GITHUB_OUTPUT + + echo "=== Processing PR ===" + echo "PR Number: ${PR_NUMBER}" + echo "Labels to apply: ${LABELS}" + echo "PR SHA: ${PR_SHA}" + + - name: Apply labels to PR + if: steps.check-artifact.outputs.artifact-exists == 'true' && steps.pr-metadata.outputs.labels != '' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const applyLabels = require('./.github/scripts/apply-labels.js'); + + const prNumber = parseInt('${{ steps.pr-metadata.outputs.pr-number }}'); + const labelsString = '${{ steps.pr-metadata.outputs.labels }}'; + + const result = await applyLabels(github, context, core, prNumber, labelsString); + + if (result.success) { + core.setOutput('labels-applied', result.appliedLabels.join(', ')); + core.setOutput('should-comment', 'true'); + } else { + console.log(`Skipped: ${result.reason || 'unknown'}`); + core.setOutput('should-comment', 'false'); + } + id: apply-labels + + - name: Comment on PR + if: steps.apply-labels.outputs.should-comment == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const postComment = require('./.github/scripts/post-comment.js'); + + const prNumber = parseInt('${{ steps.pr-metadata.outputs.pr-number }}'); + const appliedLabels = '${{ steps.apply-labels.outputs.labels-applied }}' + .split(',') + .map(l => l.trim()) + .filter(l => l !== ''); + + await postComment(github, context, prNumber, appliedLabels); + + - name: Workflow summary + if: always() + run: | + echo "## PR Label Apply Workflow Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ steps.check-artifact.outputs.artifact-exists }}" == "true" ]]; then + echo "โœ… PR metadata artifact found" >> $GITHUB_STEP_SUMMARY + echo "- **PR Number**: #${{ steps.pr-metadata.outputs.pr-number }}" >> $GITHUB_STEP_SUMMARY + echo "- **Labels**: ${{ steps.pr-metadata.outputs.labels }}" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ steps.apply-labels.outputs.should-comment }}" == "true" ]]; then + echo "- **Status**: Labels applied successfully โœ…" >> $GITHUB_STEP_SUMMARY + elif [[ -z "${{ steps.pr-metadata.outputs.labels }}" ]]; then + echo "- **Status**: No labels to apply (no matching file patterns)" >> $GITHUB_STEP_SUMMARY + else + echo "- **Status**: Labels already present on PR" >> $GITHUB_STEP_SUMMARY + fi + else + echo "โš ๏ธ No PR metadata artifact found" >> $GITHUB_STEP_SUMMARY + echo "This may happen if the analysis workflow failed or produced no labels." >> $GITHUB_STEP_SUMMARY + fi \ No newline at end of file diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml new file mode 100644 index 00000000..1be6ce62 --- /dev/null +++ b/.github/workflows/test-scripts.yml @@ -0,0 +1,55 @@ +# Workflow to test JavaScript scripts used in PR labeling +# This ensures our scripts work correctly before they're used in production + +name: Test Scripts + +on: + push: + branches: + - main + paths: + - '.github/scripts/**' + - '.github/workflows/test-scripts.yml' + pull_request: + paths: + - '.github/scripts/**' + - '.github/workflows/test-scripts.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test-javascript: + name: Test JavaScript Scripts + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run apply-labels tests + run: | + cd .github/scripts + node apply-labels.test.js + + - name: Run post-comment tests + run: | + cd .github/scripts + node post-comment.test.js + + - name: Test summary + if: always() + run: | + echo "## Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "โœ… All JavaScript tests completed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Tests Run:" >> $GITHUB_STEP_SUMMARY + echo "- apply-labels.test.js" >> $GITHUB_STEP_SUMMARY + echo "- post-comment.test.js" >> $GITHUB_STEP_SUMMARY \ No newline at end of file From 82f6a3a588b420433161b13afd5b6d69fd1b8ca0 Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Sun, 19 Oct 2025 17:57:10 -0500 Subject: [PATCH 02/11] Updated auto-labeling workflows with color-coded labels --- .github/scripts/apply-labels.js | 72 ------------- .github/scripts/apply-labels.test.js | 131 ------------------------ .github/scripts/post-comment.js | 33 ------ .github/scripts/post-comment.test.js | 106 ------------------- .github/workflows/color-labels.yml | 63 ++++++++++++ .github/workflows/pr-label-analysis.yml | 31 ++---- .github/workflows/pr-label-apply.yml | 119 ++++++++++----------- .github/workflows/test-scripts.yml | 55 ---------- 8 files changed, 126 insertions(+), 484 deletions(-) delete mode 100644 .github/scripts/apply-labels.js delete mode 100644 .github/scripts/apply-labels.test.js delete mode 100644 .github/scripts/post-comment.js delete mode 100644 .github/scripts/post-comment.test.js create mode 100644 .github/workflows/color-labels.yml delete mode 100644 .github/workflows/test-scripts.yml diff --git a/.github/scripts/apply-labels.js b/.github/scripts/apply-labels.js deleted file mode 100644 index abd3d864..00000000 --- a/.github/scripts/apply-labels.js +++ /dev/null @@ -1,72 +0,0 @@ -/** - - * @param {Object} github - * @param {Object} context - * @param {Object} core - * @param {number} prNumber - * @param {string} labelsString - */ -async function applyLabels(github, context, core, prNumber, labelsString) { - if (!labelsString) { - console.log('No labels to apply'); - return { success: false, reason: 'no-labels' }; - } - - const labels = labelsString.split(',') - .map(l => l.trim()) - .filter(l => l !== ''); - - if (labels.length === 0) { - console.log('No labels to apply after filtering'); - return { success: false, reason: 'empty-after-filter' }; - } - - console.log(`Applying labels to PR #${prNumber}: ${labels.join(', ')}`); - - try { - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - - console.log(`PR #${prNumber} current labels: ${pr.labels.map(l => l.name).join(', ')}`); - - const currentLabels = pr.labels.map(l => l.name); - const newLabels = labels.filter(l => !currentLabels.includes(l)); - - if (newLabels.length === 0) { - console.log('All labels are already applied to the PR'); - return { success: false, reason: 'already-applied' }; - } - - console.log(`Adding new labels: ${newLabels.join(', ')}`); - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: newLabels - }); - - console.log('โœ… Labels applied successfully!'); - - return { - success: true, - appliedLabels: newLabels, - shouldComment: true - }; - - } catch (error) { - console.error('โŒ Error applying labels:', error.message); - - if (error.status === 404) { - console.log('PR not found - it may have been deleted or closed'); - return { success: false, reason: 'pr-not-found' }; - } - - throw error; - } - } - - module.exports = applyLabels; \ No newline at end of file diff --git a/.github/scripts/apply-labels.test.js b/.github/scripts/apply-labels.test.js deleted file mode 100644 index 458d65a5..00000000 --- a/.github/scripts/apply-labels.test.js +++ /dev/null @@ -1,131 +0,0 @@ - - -const applyLabels = require('./apply-labels'); - -const createMockGitHub = (prLabels = [], shouldFail = false) => ({ - rest: { - pulls: { - get: async ({ pull_number }) => { - if (shouldFail) { - const error = new Error('Not Found'); - error.status = 404; - throw error; - } - return { - data: { - number: pull_number, - labels: prLabels.map(name => ({ name })) - } - }; - } - }, - issues: { - addLabels: async ({ issue_number, labels }) => { - console.log(`Mock: Adding labels ${labels.join(', ')} to PR #${issue_number}`); - return { data: {} }; - } - } - } -}); - -const mockContext = { - repo: { - owner: 'apple', - repo: 'container' - } -}; - -const mockCore = { - setOutput: (name, value) => { - console.log(`Mock Core Output: ${name} = ${value}`); - } -}; - -async function runTests() { - console.log('๐Ÿงช Running tests for apply-labels.js\n'); - - console.log('Test 1: Apply new labels to PR with no existing labels'); - const result1 = await applyLabels( - createMockGitHub([]), - mockContext, - mockCore, - 123, - 'cli,documentation' - ); - console.assert(result1.success === true, 'Should succeed'); - console.assert(result1.appliedLabels.length === 2, 'Should apply 2 labels'); - console.log('โœ… Test 1 passed\n'); - - console.log('Test 2: Skip labels that are already applied'); - const result2 = await applyLabels( - createMockGitHub(['cli', 'documentation']), - mockContext, - mockCore, - 123, - 'cli,documentation' - ); - console.assert(result2.success === false, 'Should return false'); - console.assert(result2.reason === 'already-applied', 'Reason should be already-applied'); - console.log('โœ… Test 2 passed\n'); - - console.log('Test 3: Apply only new labels when some already exist'); - const result3 = await applyLabels( - createMockGitHub(['cli']), - mockContext, - mockCore, - 123, - 'cli,documentation,tests' - ); - console.assert(result3.success === true, 'Should succeed'); - console.assert(result3.appliedLabels.length === 2, 'Should apply 2 new labels'); - console.assert(result3.appliedLabels.includes('documentation'), 'Should include documentation'); - console.assert(result3.appliedLabels.includes('tests'), 'Should include tests'); - console.log('โœ… Test 3 passed\n'); - - console.log('Test 4: Handle empty label string'); - const result4 = await applyLabels( - createMockGitHub([]), - mockContext, - mockCore, - 123, - '' - ); - console.assert(result4.success === false, 'Should return false'); - console.assert(result4.reason === 'no-labels', 'Reason should be no-labels'); - console.log('โœ… Test 4 passed\n'); - - console.log('Test 5: Handle whitespace and empty values in label string'); - const result5 = await applyLabels( - createMockGitHub([]), - mockContext, - mockCore, - 123, - 'cli, , documentation, ' - ); - console.assert(result5.success === true, 'Should succeed'); - console.assert(result5.appliedLabels.length === 2, 'Should apply 2 labels after filtering'); - console.log('โœ… Test 5 passed\n'); - - console.log('Test 6: Handle PR not found gracefully'); - const result6 = await applyLabels( - createMockGitHub([], true), - mockContext, - mockCore, - 999, - 'cli' - ); - console.assert(result6.success === false, 'Should return false'); - console.assert(result6.reason === 'pr-not-found', 'Reason should be pr-not-found'); - console.log('โœ… Test 6 passed\n'); - - console.log('๐ŸŽ‰ All tests passed!'); -} - -if (require.main === module) { - runTests().catch(error => { - console.error('โŒ Test failed:', error); - process.exit(1); - }); -} - -module.exports = runTests; \ No newline at end of file diff --git a/.github/scripts/post-comment.js b/.github/scripts/post-comment.js deleted file mode 100644 index bd9783f1..00000000 --- a/.github/scripts/post-comment.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @param {Object} github - * @param {Object} context - * @param {number} prNumber - * @param {Array} appliedLabels - */ -async function postComment(github, context, prNumber, appliedLabels) { - if (!appliedLabels || appliedLabels.length === 0) { - console.log('No labels to comment about'); - return { success: false }; - } - - const labelBadges = appliedLabels.map(l => `\`${l}\``).join(', '); - const comment = `๐Ÿท๏ธ **Auto-labeler** has applied the following labels: ${labelBadges}`; - - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: comment - }); - - console.log('โœ… Comment posted successfully'); - return { success: true }; - - } catch (error) { - console.error('Failed to post comment:', error.message); - return { success: false, error: error.message }; - } - } - - module.exports = postComment; \ No newline at end of file diff --git a/.github/scripts/post-comment.test.js b/.github/scripts/post-comment.test.js deleted file mode 100644 index 1c79f19a..00000000 --- a/.github/scripts/post-comment.test.js +++ /dev/null @@ -1,106 +0,0 @@ -const postComment = require('./post-comment'); - -const createMockGitHub = (shouldFail = false) => ({ - rest: { - issues: { - createComment: async ({ issue_number, body }) => { - if (shouldFail) { - throw new Error('API Error: Unable to post comment'); - } - console.log(`Mock: Posted comment on PR #${issue_number}`); - console.log(`Comment body: ${body}`); - return { data: {} }; - } - } - } -}); - -const mockContext = { - repo: { - owner: 'apple', - repo: 'container' - } -}; - -async function runTests() { - console.log('๐Ÿงช Running tests for post-comment.js\n'); - - console.log('Test 1: Post comment with single label'); - const result1 = await postComment( - createMockGitHub(), - mockContext, - 123, - ['cli'] - ); - console.assert(result1.success === true, 'Should succeed'); - console.log('โœ… Test 1 passed\n'); - - console.log('Test 2: Post comment with multiple labels'); - const result2 = await postComment( - createMockGitHub(), - mockContext, - 123, - ['cli', 'documentation', 'tests'] - ); - console.assert(result2.success === true, 'Should succeed'); - console.log('โœ… Test 2 passed\n'); - - console.log('Test 3: Handle empty labels array'); - const result3 = await postComment( - createMockGitHub(), - mockContext, - 123, - [] - ); - console.assert(result3.success === false, 'Should return false for empty array'); - console.log('โœ… Test 3 passed\n'); - - console.log('Test 4: Handle null labels'); - const result4 = await postComment( - createMockGitHub(), - mockContext, - 123, - null - ); - console.assert(result4.success === false, 'Should return false for null'); - console.log('โœ… Test 4 passed\n'); - - console.log('Test 5: Handle API failure gracefully'); - const result5 = await postComment( - createMockGitHub(true), - mockContext, - 123, - ['cli'] - ); - console.assert(result5.success === false, 'Should return false on failure'); - console.assert(result5.error !== undefined, 'Should include error message'); - console.log('โœ… Test 5 passed\n'); - - console.log('Test 6: Verify comment format is correct'); - const mockGitHubWithVerification = { - rest: { - issues: { - createComment: async ({ body }) => { - console.assert(body.includes('๐Ÿท๏ธ'), 'Should include emoji'); - console.assert(body.includes('Auto-labeler'), 'Should mention auto-labeler'); - console.assert(body.includes('`cli`'), 'Should format labels as code'); - console.log('Comment format verified'); - return { data: {} }; - } - } - } - }; - await postComment(mockGitHubWithVerification, mockContext, 123, ['cli']); - console.log('โœ… Test 6 passed\n'); - - console.log('๐ŸŽ‰ All tests passed!'); -} - -if (require.main === module) { - runTests().catch(error => { - console.error('โŒ Test failed:', error); - process.exit(1); - }); -} - -module.exports = runTests; \ No newline at end of file diff --git a/.github/workflows/color-labels.yml b/.github/workflows/color-labels.yml new file mode 100644 index 00000000..11e42840 --- /dev/null +++ b/.github/workflows/color-labels.yml @@ -0,0 +1,63 @@ +name: Color Labels + +on: + push: + branches: + - main + paths: + - '.github/workflows/color-labels.yml' + workflow_dispatch: + +permissions: + issues: write + +jobs: + create-labels: + name: Create labels with colors + runs-on: ubuntu-latest + + steps: + - name: Create or update labels + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const labels = [ + { name: 'cli', color: '0E8A16', description: 'Changes to CLI components' }, + ]; + + for (const label of labels) { + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name + }); + + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description + }); + + console.log(`โœ… Updated label: ${label.name}`); + } catch (error) { + if (error.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description + }); + + console.log(`โœ… Created label: ${label.name}`); + } else { + throw error; + } + } + } + + console.log('๐ŸŽ‰ All labels created/updated successfully!'); \ No newline at end of file diff --git a/.github/workflows/pr-label-analysis.yml b/.github/workflows/pr-label-analysis.yml index 581c3505..abb5f5fc 100644 --- a/.github/workflows/pr-label-analysis.yml +++ b/.github/workflows/pr-label-analysis.yml @@ -1,14 +1,9 @@ -# Non-privileged workflow that analyzes PR content -# This workflow runs with minimal permissions and processes untrusted code safely -# Following OpenSSF best practices: https://openssf.org/blog/2024/08/12/mitigating-attack-vectors-in-github-workflows/ - name: PR Label Analysis on: pull_request: types: [opened] - permissions: contents: read @@ -18,22 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout PR branch uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - name: Checkout labeler config from main - uses: actions/checkout@v4 - with: - ref: main - sparse-checkout: | - .github/labeler.yml - sparse-checkout-cone-mode: false - path: trusted-config - - name: Get changed files id: changed-files uses: tj-actions/changed-files@v44 @@ -42,6 +27,17 @@ jobs: cli: - 'Sources/CLI/**' - 'Sources/ContainerCommands/**' + builder: + - 'Sources/ContainerBuild/**' + - 'Sources/NativeBuilder/**' + documentation: + - '**/*.md' + - 'docs/**' + tests: + - 'Tests/**' + - '**/*Tests.swift' + ci: + - '.github/**' - name: Build label list id: build-labels @@ -79,11 +75,6 @@ jobs: echo "${{ github.event.pull_request.number }}" > ./pr-metadata/pr-number.txt echo "${{ steps.build-labels.outputs.labels }}" > ./pr-metadata/labels.txt echo "${{ github.event.pull_request.head.sha }}" > ./pr-metadata/pr-sha.txt - - echo "=== PR Metadata ===" - cat ./pr-metadata/pr-number.txt - cat ./pr-metadata/labels.txt - cat ./pr-metadata/pr-sha.txt - name: Upload PR metadata as artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pr-label-apply.yml b/.github/workflows/pr-label-apply.yml index 6616c5d7..6b25a46d 100644 --- a/.github/workflows/pr-label-apply.yml +++ b/.github/workflows/pr-label-apply.yml @@ -1,8 +1,3 @@ -# Privileged workflow that applies labels to PRs -# This workflow runs with write permissions but does NOT checkout or run untrusted code -# It only processes the PR number and labels from the artifact created by pr-label-analysis.yml -# Following OpenSSF best practices: https://openssf.org/blog/2024/08/12/mitigating-attack-vectors-in-github-workflows/ - name: PR Label Apply on: @@ -11,7 +6,6 @@ on: types: - completed - permissions: contents: read pull-requests: write @@ -21,19 +15,9 @@ jobs: apply-labels: name: Apply labels to PR runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - - - name: Checkout scripts from main - uses: actions/checkout@v4 - with: - ref: main - sparse-checkout: | - .github/scripts/ - sparse-checkout-cone-mode: false - - name: Download PR metadata artifact uses: actions/download-artifact@v4 with: @@ -44,13 +28,12 @@ jobs: continue-on-error: true id: download-artifact - - name: Check if artifact was downloaded + - name: Check if artifact exists id: check-artifact run: | METADATA_DIR=$(find . -type d -name "pr-metadata-*" 2>/dev/null | head -n 1) if [ -z "$METADATA_DIR" ]; then - echo "No PR metadata artifact found. This might be expected if the analysis workflow had no labels to apply." echo "artifact-exists=false" >> $GITHUB_OUTPUT exit 0 fi @@ -64,11 +47,6 @@ jobs: run: | METADATA_DIR="${{ steps.check-artifact.outputs.metadata-dir }}" - if [ ! -f "${METADATA_DIR}/pr-number.txt" ]; then - echo "Error: pr-number.txt not found in artifact" - exit 1 - fi - PR_NUMBER=$(cat "${METADATA_DIR}/pr-number.txt") LABELS=$(cat "${METADATA_DIR}/labels.txt" || echo "") PR_SHA=$(cat "${METADATA_DIR}/pr-sha.txt" || echo "") @@ -76,69 +54,76 @@ jobs: echo "pr-number=${PR_NUMBER}" >> $GITHUB_OUTPUT echo "labels=${LABELS}" >> $GITHUB_OUTPUT echo "pr-sha=${PR_SHA}" >> $GITHUB_OUTPUT - - echo "=== Processing PR ===" - echo "PR Number: ${PR_NUMBER}" - echo "Labels to apply: ${LABELS}" - echo "PR SHA: ${PR_SHA}" - - name: Apply labels to PR + - name: Apply labels if: steps.check-artifact.outputs.artifact-exists == 'true' && steps.pr-metadata.outputs.labels != '' uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const applyLabels = require('./.github/scripts/apply-labels.js'); - const prNumber = parseInt('${{ steps.pr-metadata.outputs.pr-number }}'); const labelsString = '${{ steps.pr-metadata.outputs.labels }}'; - const result = await applyLabels(github, context, core, prNumber, labelsString); + if (!labelsString) return; + + const labels = labelsString.split(',').map(l => l.trim()).filter(l => l !== ''); + if (labels.length === 0) return; - if (result.success) { - core.setOutput('labels-applied', result.appliedLabels.join(', ')); + try { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const currentLabels = pr.labels.map(l => l.name); + const newLabels = labels.filter(l => !currentLabels.includes(l)); + + if (newLabels.length === 0) { + console.log('All labels already applied'); + return; + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: newLabels + }); + + core.setOutput('labels-applied', newLabels.join(',')); core.setOutput('should-comment', 'true'); - } else { - console.log(`Skipped: ${result.reason || 'unknown'}`); - core.setOutput('should-comment', 'false'); + + } catch (error) { + if (error.status === 404) return; + throw error; } id: apply-labels - - name: Comment on PR + - name: Post comment if: steps.apply-labels.outputs.should-comment == 'true' uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const postComment = require('./.github/scripts/post-comment.js'); - const prNumber = parseInt('${{ steps.pr-metadata.outputs.pr-number }}'); - const appliedLabels = '${{ steps.apply-labels.outputs.labels-applied }}' - .split(',') - .map(l => l.trim()) - .filter(l => l !== ''); + const labelsString = '${{ steps.apply-labels.outputs.labels-applied }}'; - await postComment(github, context, prNumber, appliedLabels); - - - name: Workflow summary - if: always() - run: | - echo "## PR Label Apply Workflow Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [[ "${{ steps.check-artifact.outputs.artifact-exists }}" == "true" ]]; then - echo "โœ… PR metadata artifact found" >> $GITHUB_STEP_SUMMARY - echo "- **PR Number**: #${{ steps.pr-metadata.outputs.pr-number }}" >> $GITHUB_STEP_SUMMARY - echo "- **Labels**: ${{ steps.pr-metadata.outputs.labels }}" >> $GITHUB_STEP_SUMMARY + if (!labelsString) return; + + const appliedLabels = labelsString.split(',').map(l => l.trim()).filter(l => l !== ''); + if (appliedLabels.length === 0) return; + + const labelBadges = appliedLabels.map(l => `\`${l}\``).join(', '); + const comment = `๐Ÿท๏ธ **Auto-labeler** has applied the following labels: ${labelBadges}`; - if [[ "${{ steps.apply-labels.outputs.should-comment }}" == "true" ]]; then - echo "- **Status**: Labels applied successfully โœ…" >> $GITHUB_STEP_SUMMARY - elif [[ -z "${{ steps.pr-metadata.outputs.labels }}" ]]; then - echo "- **Status**: No labels to apply (no matching file patterns)" >> $GITHUB_STEP_SUMMARY - else - echo "- **Status**: Labels already present on PR" >> $GITHUB_STEP_SUMMARY - fi - else - echo "โš ๏ธ No PR metadata artifact found" >> $GITHUB_STEP_SUMMARY - echo "This may happen if the analysis workflow failed or produced no labels." >> $GITHUB_STEP_SUMMARY - fi \ No newline at end of file + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: comment + }); + } catch (error) { + console.error('Failed to post comment:', error.message); + } \ No newline at end of file diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml deleted file mode 100644 index 1be6ce62..00000000 --- a/.github/workflows/test-scripts.yml +++ /dev/null @@ -1,55 +0,0 @@ -# Workflow to test JavaScript scripts used in PR labeling -# This ensures our scripts work correctly before they're used in production - -name: Test Scripts - -on: - push: - branches: - - main - paths: - - '.github/scripts/**' - - '.github/workflows/test-scripts.yml' - pull_request: - paths: - - '.github/scripts/**' - - '.github/workflows/test-scripts.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - test-javascript: - name: Test JavaScript Scripts - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Run apply-labels tests - run: | - cd .github/scripts - node apply-labels.test.js - - - name: Run post-comment tests - run: | - cd .github/scripts - node post-comment.test.js - - - name: Test summary - if: always() - run: | - echo "## Test Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "โœ… All JavaScript tests completed" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Tests Run:" >> $GITHUB_STEP_SUMMARY - echo "- apply-labels.test.js" >> $GITHUB_STEP_SUMMARY - echo "- post-comment.test.js" >> $GITHUB_STEP_SUMMARY \ No newline at end of file From f59657ecbd0f4a05bead92bf8b9243ffd3395dd8 Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Sun, 19 Oct 2025 18:13:43 -0500 Subject: [PATCH 03/11] Fix: Use native git commands instead of blocked third-party action --- .github/workflows/pr-label-analysis.yml | 50 +++++++------------------ 1 file changed, 13 insertions(+), 37 deletions(-) diff --git a/.github/workflows/pr-label-analysis.yml b/.github/workflows/pr-label-analysis.yml index abb5f5fc..cc09a16d 100644 --- a/.github/workflows/pr-label-analysis.yml +++ b/.github/workflows/pr-label-analysis.yml @@ -2,7 +2,7 @@ name: PR Label Analysis on: pull_request: - types: [opened] + types: [opened, reopened, synchronize] permissions: contents: read @@ -19,55 +19,31 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - name: Get changed files - id: changed-files - uses: tj-actions/changed-files@v44 - with: - files_yaml: | - cli: - - 'Sources/CLI/**' - - 'Sources/ContainerCommands/**' - builder: - - 'Sources/ContainerBuild/**' - - 'Sources/NativeBuilder/**' - documentation: - - '**/*.md' - - 'docs/**' - tests: - - 'Tests/**' - - '**/*Tests.swift' - ci: - - '.github/**' - - - name: Build label list + - name: Get changed files and build labels id: build-labels run: | + git fetch origin ${{ github.event.pull_request.base.ref }} + + CHANGED_FILES=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }}...HEAD) + + echo "Changed files:" + echo "$CHANGED_FILES" + LABELS="" - if [[ "${{ steps.changed-files.outputs.cli_any_changed }}" == "true" ]]; then + if echo "$CHANGED_FILES" | grep -qE '^Sources/CLI/|^Sources/ContainerCommands/'; then LABELS="${LABELS}cli," fi - if [[ "${{ steps.changed-files.outputs.builder_any_changed }}" == "true" ]]; then + if echo "$CHANGED_FILES" | grep -qE '^Sources/ContainerBuild/|^Sources/NativeBuilder/'; then LABELS="${LABELS}area:builder," fi - if [[ "${{ steps.changed-files.outputs.documentation_any_changed }}" == "true" ]]; then + if echo "$CHANGED_FILES" | grep -qE '\.md$|^docs/'; then LABELS="${LABELS}documentation," fi - if [[ "${{ steps.changed-files.outputs.tests_any_changed }}" == "true" ]]; then - LABELS="${LABELS}tests," - fi - - if [[ "${{ steps.changed-files.outputs.ci_any_changed }}" == "true" ]]; then - LABELS="${LABELS}ci," - fi - - LABELS="${LABELS%,}" - - echo "labels=${LABELS}" >> $GITHUB_OUTPUT - echo "PR #${{ github.event.pull_request.number }} should have labels: ${LABELS}" + if echo "$CHANGED_FILES" | grep -qE '^Tests/|Tests\.swift - name: Save PR metadata run: | From 9e021309805cea1dae78ba99d13bb51a2ed223a5 Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Sun, 19 Oct 2025 18:14:33 -0500 Subject: [PATCH 04/11] updating the typos --- .github/workflows/pr-label-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-label-analysis.yml b/.github/workflows/pr-label-analysis.yml index cc09a16d..16cadd56 100644 --- a/.github/workflows/pr-label-analysis.yml +++ b/.github/workflows/pr-label-analysis.yml @@ -2,7 +2,7 @@ name: PR Label Analysis on: pull_request: - types: [opened, reopened, synchronize] + types: [opened] permissions: contents: read From 9623b300569c259c966a9bad7f89a4b5d08e734b Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Sun, 19 Oct 2025 22:03:46 -0500 Subject: [PATCH 05/11] updating the files for CLI only --- .github/workflows/pr-label-analysis.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/pr-label-analysis.yml b/.github/workflows/pr-label-analysis.yml index 16cadd56..fe8c57b0 100644 --- a/.github/workflows/pr-label-analysis.yml +++ b/.github/workflows/pr-label-analysis.yml @@ -34,16 +34,6 @@ jobs: if echo "$CHANGED_FILES" | grep -qE '^Sources/CLI/|^Sources/ContainerCommands/'; then LABELS="${LABELS}cli," fi - - if echo "$CHANGED_FILES" | grep -qE '^Sources/ContainerBuild/|^Sources/NativeBuilder/'; then - LABELS="${LABELS}area:builder," - fi - - if echo "$CHANGED_FILES" | grep -qE '\.md$|^docs/'; then - LABELS="${LABELS}documentation," - fi - - if echo "$CHANGED_FILES" | grep -qE '^Tests/|Tests\.swift - name: Save PR metadata run: | From 30e9ab13c3e6f2aa5895fbf22bf07ad018d86eec Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Mon, 20 Oct 2025 18:48:52 -0500 Subject: [PATCH 06/11] Refactor to use actions/labeler@v5 with PR number from artifact per Katie's security recommendations --- .github/labeler.yml | 4 +- .github/workflows/color-labels.yml | 6 +- .github/workflows/pr-label-analysis.yml | 18 ---- .github/workflows/pr-label-apply.yml | 107 ++++-------------------- 4 files changed, 21 insertions(+), 114 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index b594358b..130e4c69 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,5 +1,5 @@ cli: - changed-files: - any-glob-to-any-file: - - 'Sources/CLI/**/*' - - 'Sources/ContainerCommands/**/*' \ No newline at end of file + - 'Sources/CLI/**' + - 'Sources/ContainerCommands/**' \ No newline at end of file diff --git a/.github/workflows/color-labels.yml b/.github/workflows/color-labels.yml index 11e42840..e0ebd63e 100644 --- a/.github/workflows/color-labels.yml +++ b/.github/workflows/color-labels.yml @@ -9,12 +9,14 @@ on: workflow_dispatch: permissions: - issues: write + contents: read jobs: create-labels: name: Create labels with colors runs-on: ubuntu-latest + permissions: + issues: write steps: - name: Create or update labels @@ -23,7 +25,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const labels = [ - { name: 'cli', color: '0E8A16', description: 'Changes to CLI components' }, + { name: 'cli', color: '0E8A16', description: 'Changes to CLI components' } ]; for (const label of labels) { diff --git a/.github/workflows/pr-label-analysis.yml b/.github/workflows/pr-label-analysis.yml index fe8c57b0..eff2e8f9 100644 --- a/.github/workflows/pr-label-analysis.yml +++ b/.github/workflows/pr-label-analysis.yml @@ -19,28 +19,10 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - name: Get changed files and build labels - id: build-labels - run: | - git fetch origin ${{ github.event.pull_request.base.ref }} - - CHANGED_FILES=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }}...HEAD) - - echo "Changed files:" - echo "$CHANGED_FILES" - - LABELS="" - - if echo "$CHANGED_FILES" | grep -qE '^Sources/CLI/|^Sources/ContainerCommands/'; then - LABELS="${LABELS}cli," - fi - - name: Save PR metadata run: | mkdir -p ./pr-metadata echo "${{ github.event.pull_request.number }}" > ./pr-metadata/pr-number.txt - echo "${{ steps.build-labels.outputs.labels }}" > ./pr-metadata/labels.txt - echo "${{ github.event.pull_request.head.sha }}" > ./pr-metadata/pr-sha.txt - name: Upload PR metadata as artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pr-label-apply.yml b/.github/workflows/pr-label-apply.yml index 6b25a46d..c4cd8850 100644 --- a/.github/workflows/pr-label-apply.yml +++ b/.github/workflows/pr-label-apply.yml @@ -8,14 +8,15 @@ on: permissions: contents: read - pull-requests: write - issues: write jobs: apply-labels: name: Apply labels to PR runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'success' }} + permissions: + contents: read + pull-requests: write steps: - name: Download PR metadata artifact @@ -28,102 +29,24 @@ jobs: continue-on-error: true id: download-artifact - - name: Check if artifact exists - id: check-artifact + - name: Read PR number + id: pr-number run: | METADATA_DIR=$(find . -type d -name "pr-metadata-*" 2>/dev/null | head -n 1) if [ -z "$METADATA_DIR" ]; then - echo "artifact-exists=false" >> $GITHUB_OUTPUT - exit 0 + echo "No metadata found" + exit 1 fi - echo "artifact-exists=true" >> $GITHUB_OUTPUT - echo "metadata-dir=${METADATA_DIR}" >> $GITHUB_OUTPUT - - - name: Read PR metadata - if: steps.check-artifact.outputs.artifact-exists == 'true' - id: pr-metadata - run: | - METADATA_DIR="${{ steps.check-artifact.outputs.metadata-dir }}" - PR_NUMBER=$(cat "${METADATA_DIR}/pr-number.txt") - LABELS=$(cat "${METADATA_DIR}/labels.txt" || echo "") - PR_SHA=$(cat "${METADATA_DIR}/pr-sha.txt" || echo "") - - echo "pr-number=${PR_NUMBER}" >> $GITHUB_OUTPUT - echo "labels=${LABELS}" >> $GITHUB_OUTPUT - echo "pr-sha=${PR_SHA}" >> $GITHUB_OUTPUT + echo "number=${PR_NUMBER}" >> $GITHUB_OUTPUT + echo "PR Number: ${PR_NUMBER}" - - name: Apply labels - if: steps.check-artifact.outputs.artifact-exists == 'true' && steps.pr-metadata.outputs.labels != '' - uses: actions/github-script@v7 + - name: Apply labels using labeler + uses: actions/labeler@v5 with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const prNumber = parseInt('${{ steps.pr-metadata.outputs.pr-number }}'); - const labelsString = '${{ steps.pr-metadata.outputs.labels }}'; - - if (!labelsString) return; - - const labels = labelsString.split(',').map(l => l.trim()).filter(l => l !== ''); - if (labels.length === 0) return; - - try { - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - - const currentLabels = pr.labels.map(l => l.name); - const newLabels = labels.filter(l => !currentLabels.includes(l)); - - if (newLabels.length === 0) { - console.log('All labels already applied'); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: newLabels - }); - - core.setOutput('labels-applied', newLabels.join(',')); - core.setOutput('should-comment', 'true'); - - } catch (error) { - if (error.status === 404) return; - throw error; - } - id: apply-labels - - - name: Post comment - if: steps.apply-labels.outputs.should-comment == 'true' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const prNumber = parseInt('${{ steps.pr-metadata.outputs.pr-number }}'); - const labelsString = '${{ steps.apply-labels.outputs.labels-applied }}'; - - if (!labelsString) return; - - const appliedLabels = labelsString.split(',').map(l => l.trim()).filter(l => l !== ''); - if (appliedLabels.length === 0) return; - - const labelBadges = appliedLabels.map(l => `\`${l}\``).join(', '); - const comment = `๐Ÿท๏ธ **Auto-labeler** has applied the following labels: ${labelBadges}`; - - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: comment - }); - } catch (error) { - console.error('Failed to post comment:', error.message); - } \ No newline at end of file + pr-number: ${{ steps.pr-number.outputs.number }} + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + sync-labels: true \ No newline at end of file From 2ddaa14bb272f1b76bb8424a4c1d6c6c3e757bed Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Mon, 20 Oct 2025 18:54:30 -0500 Subject: [PATCH 07/11] removed unessacry checkout --- .github/workflows/pr-label-analysis.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/pr-label-analysis.yml b/.github/workflows/pr-label-analysis.yml index eff2e8f9..e8b20738 100644 --- a/.github/workflows/pr-label-analysis.yml +++ b/.github/workflows/pr-label-analysis.yml @@ -13,12 +13,6 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout PR branch - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - name: Save PR metadata run: | mkdir -p ./pr-metadata From a95cf4f05c6c59651ac6f1b09f698ddd28da1f0a Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Thu, 23 Oct 2025 12:38:07 -0500 Subject: [PATCH 08/11] deleted the color labels file --- .github/workflows/color-labels.yml | 65 ------------------------------ 1 file changed, 65 deletions(-) delete mode 100644 .github/workflows/color-labels.yml diff --git a/.github/workflows/color-labels.yml b/.github/workflows/color-labels.yml deleted file mode 100644 index e0ebd63e..00000000 --- a/.github/workflows/color-labels.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Color Labels - -on: - push: - branches: - - main - paths: - - '.github/workflows/color-labels.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - create-labels: - name: Create labels with colors - runs-on: ubuntu-latest - permissions: - issues: write - - steps: - - name: Create or update labels - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const labels = [ - { name: 'cli', color: '0E8A16', description: 'Changes to CLI components' } - ]; - - for (const label of labels) { - try { - await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name - }); - - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description - }); - - console.log(`โœ… Updated label: ${label.name}`); - } catch (error) { - if (error.status === 404) { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description - }); - - console.log(`โœ… Created label: ${label.name}`); - } else { - throw error; - } - } - } - - console.log('๐ŸŽ‰ All labels created/updated successfully!'); \ No newline at end of file From 89d0389e7226dd8d4a136ee980d334ef7cef04f2 Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Thu, 23 Oct 2025 17:17:11 -0500 Subject: [PATCH 09/11] reverted the checkout steps --- .github/workflows/pr-label-apply.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pr-label-apply.yml b/.github/workflows/pr-label-apply.yml index c4cd8850..a68f8bff 100644 --- a/.github/workflows/pr-label-apply.yml +++ b/.github/workflows/pr-label-apply.yml @@ -19,6 +19,9 @@ jobs: pull-requests: write steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Download PR metadata artifact uses: actions/download-artifact@v4 with: From 24599bde0c90e6358826661750425ab54a79c6d4 Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Sat, 13 Dec 2025 16:12:22 -0600 Subject: [PATCH 10/11] feat: add detailed I/O statistics to container stats command --- .../ContainerClient/Core/ContainerStats.swift | 36 +++++++++- .../Container/ContainerStats.swift | 66 +++++++++++++++++++ .../SandboxService.swift | 39 ++++++++++- docs/command-reference.md | 8 ++- docs/how-to.md | 26 ++++++++ 5 files changed, 170 insertions(+), 5 deletions(-) diff --git a/Sources/ContainerClient/Core/ContainerStats.swift b/Sources/ContainerClient/Core/ContainerStats.swift index 6c9abe3f..a1f2eb6a 100644 --- a/Sources/ContainerClient/Core/ContainerStats.swift +++ b/Sources/ContainerClient/Core/ContainerStats.swift @@ -36,6 +36,24 @@ public struct ContainerStats: Sendable, Codable { public var blockWriteBytes: UInt64 /// Number of processes in the container public var numProcesses: UInt64 + + // Extended I/O metrics + /// Read operations per second + public var readOpsPerSec: Double? + /// Write operations per second + public var writeOpsPerSec: Double? + /// Average read latency in milliseconds + public var readLatencyMs: Double? + /// Average write latency in milliseconds + public var writeLatencyMs: Double? + /// Average fsync latency in milliseconds + public var fsyncLatencyMs: Double? + /// I/O queue depth + public var queueDepth: UInt64? + /// Percentage of dirty pages + public var dirtyPagesPercent: Double? + /// Storage backend type (e.g., "apfs", "ext4", "virtio") + public var storageBackend: String? public init( id: String, @@ -46,7 +64,15 @@ public struct ContainerStats: Sendable, Codable { networkTxBytes: UInt64, blockReadBytes: UInt64, blockWriteBytes: UInt64, - numProcesses: UInt64 + numProcesses: UInt64, + readOpsPerSec: Double? = nil, + writeOpsPerSec: Double? = nil, + readLatencyMs: Double? = nil, + writeLatencyMs: Double? = nil, + fsyncLatencyMs: Double? = nil, + queueDepth: UInt64? = nil, + dirtyPagesPercent: Double? = nil, + storageBackend: String? = nil ) { self.id = id self.memoryUsageBytes = memoryUsageBytes @@ -57,5 +83,13 @@ public struct ContainerStats: Sendable, Codable { self.blockReadBytes = blockReadBytes self.blockWriteBytes = blockWriteBytes self.numProcesses = numProcesses + self.readOpsPerSec = readOpsPerSec + self.writeOpsPerSec = writeOpsPerSec + self.readLatencyMs = readLatencyMs + self.writeLatencyMs = writeLatencyMs + self.fsyncLatencyMs = fsyncLatencyMs + self.queueDepth = queueDepth + self.dirtyPagesPercent = dirtyPagesPercent + self.storageBackend = storageBackend } } diff --git a/Sources/ContainerCommands/Container/ContainerStats.swift b/Sources/ContainerCommands/Container/ContainerStats.swift index 651b156d..e2d19ab4 100644 --- a/Sources/ContainerCommands/Container/ContainerStats.swift +++ b/Sources/ContainerCommands/Container/ContainerStats.swift @@ -34,6 +34,9 @@ extension Application { @Flag(name: .long, help: "Disable streaming stats and only pull the first result") var noStream = false + + @Flag(name: .long, help: "Display detailed I/O statistics (IOPS, latency, fsync, queue depth)") + var io = false @OptionGroup var global: Flags.Global @@ -225,6 +228,14 @@ extension Application { } private func printStatsTable(_ statsData: [StatsSnapshot]) { + if io { + printIOStatsTable(statsData) + } else { + printDefaultStatsTable(statsData) + } + } + + private func printDefaultStatsTable(_ statsData: [StatsSnapshot]) { let header = [["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]] var rows = header @@ -252,6 +263,61 @@ extension Application { let formatter = TableOutput(rows: rows) print(formatter.format()) } + + private func printIOStatsTable(_ statsData: [StatsSnapshot]) { + let header = [["CONTAINER", "READ/s", "WRITE/s", "LAT(ms)", "FSYNC(ms)", "QD", "DIRTY", "BACKEND"]] + var rows = header + + for snapshot in statsData { + let stats2 = snapshot.stats2 + + // Calculate throughput from bytes (convert to MB/s) + let readMBps = Self.formatThroughput(stats2.blockReadBytes) + let writeMBps = Self.formatThroughput(stats2.blockWriteBytes) + + // Format latency metrics + let latency = stats2.readLatencyMs != nil ? String(format: "%.1f", stats2.readLatencyMs!) : "N/A" + let fsyncLatency = stats2.fsyncLatencyMs != nil ? String(format: "%.1f", stats2.fsyncLatencyMs!) : "N/A" + + // Format queue depth + let queueDepth = stats2.queueDepth != nil ? "\(stats2.queueDepth!)" : "N/A" + + // Format dirty pages percentage + let dirty = stats2.dirtyPagesPercent != nil ? String(format: "%.1f%%", stats2.dirtyPagesPercent!) : "N/A" + + // Storage backend + let backend = stats2.storageBackend ?? "unknown" + + rows.append([ + snapshot.container.id, + readMBps, + writeMBps, + latency, + fsyncLatency, + queueDepth, + dirty, + backend + ]) + } + + // Always print header, even if no containers + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + + static func formatThroughput(_ bytes: UInt64) -> String { + let mb = 1024.0 * 1024.0 + let kb = 1024.0 + let value = Double(bytes) + + if value >= mb { + return String(format: "%.0fMB", value / mb) + } else if value >= kb { + return String(format: "%.0fKB", value / kb) + } else { + return "\(bytes)B" + } + } private func clearScreen() { // Move cursor to home position and clear from cursor to end of screen diff --git a/Sources/Services/ContainerSandboxService/SandboxService.swift b/Sources/Services/ContainerSandboxService/SandboxService.swift index 546fcdb9..87a283da 100644 --- a/Sources/Services/ContainerSandboxService/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/SandboxService.swift @@ -274,6 +274,31 @@ public actor SandboxService { let containerInfo = try await self.getContainer() let stats = try await containerInfo.container.statistics() + // Calculate I/O operations per second (IOPS) from block device stats + // TODO: The Containerization framework needs to provide readOps and writeOps + // from blockIO.devices. For now, we estimate from bytes assuming 4KB operations. + let totalReadBytes = stats.blockIO.devices.reduce(0) { $0 + $1.readBytes } + let totalWriteBytes = stats.blockIO.devices.reduce(0) { $0 + $1.writeBytes } + let estimatedReadOps = Double(totalReadBytes) / 4096.0 + let estimatedWriteOps = Double(totalWriteBytes) / 4096.0 + + // TODO: Collect latency metrics from Containerization framework + // These would ideally come from blockIO.devices with new properties: + // - readLatencyMicros, writeLatencyMicros, fsyncLatencyMicros + let readLatency: Double? = nil // stats.blockIO.averageReadLatencyMs + let writeLatency: Double? = nil // stats.blockIO.averageWriteLatencyMs + let fsyncLatency: Double? = nil // stats.blockIO.averageFsyncLatencyMs + + // TODO: Get queue depth from Containerization framework + let queueDepth: UInt64? = nil // stats.blockIO.queueDepth + + // TODO: Get dirty pages percentage from memory stats + let dirtyPages: Double? = nil // stats.memory.dirtyPagesPercent + + // TODO: Detect storage backend type from device information + // This would require inspecting the block device type in the VM + let backend: String? = "virtio" // Default for VM-based containers + let containerStats = ContainerStats( id: stats.id, memoryUsageBytes: stats.memory.usageBytes, @@ -281,9 +306,17 @@ public actor SandboxService { cpuUsageUsec: stats.cpu.usageUsec, networkRxBytes: stats.networks.reduce(0) { $0 + $1.receivedBytes }, networkTxBytes: stats.networks.reduce(0) { $0 + $1.transmittedBytes }, - blockReadBytes: stats.blockIO.devices.reduce(0) { $0 + $1.readBytes }, - blockWriteBytes: stats.blockIO.devices.reduce(0) { $0 + $1.writeBytes }, - numProcesses: stats.process.current + blockReadBytes: totalReadBytes, + blockWriteBytes: totalWriteBytes, + numProcesses: stats.process.current, + readOpsPerSec: estimatedReadOps, + writeOpsPerSec: estimatedWriteOps, + readLatencyMs: readLatency, + writeLatencyMs: writeLatency, + fsyncLatencyMs: fsyncLatency, + queueDepth: queueDepth, + dirtyPagesPercent: dirtyPages, + storageBackend: backend ) let reply = message.reply() diff --git a/docs/command-reference.md b/docs/command-reference.md index 4bb9c16c..5059413f 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -383,10 +383,12 @@ No options. Displays real-time resource usage statistics for containers. Shows CPU percentage, memory usage, network I/O, block I/O, and process count. By default, continuously updates statistics in an interactive display (like `top`). Use `--no-stream` for a single snapshot. +With the `--io` flag, displays detailed I/O performance metrics including IOPS, latency, fsync performance, queue depth, dirty pages, and storage backend type - useful for diagnosing database workloads and I/O bottlenecks. + **Usage** ```bash -container stats [--format ] [--no-stream] [--debug] [ ...] +container stats [--format ] [--no-stream] [--io] [--debug] [ ...] ``` **Arguments** @@ -397,6 +399,7 @@ container stats [--format ] [--no-stream] [--debug] [ ... * `--format `: Format of the output (values: json, table; default: table) * `--no-stream`: Disable streaming stats and only pull the first result +* `--io`: Display detailed I/O statistics (IOPS, latency, fsync, queue depth) **Examples** @@ -410,6 +413,9 @@ container stats web db cache # get a single snapshot of stats (non-interactive) container stats --no-stream web +# display detailed I/O statistics for database workload analysis +container stats --io --no-stream postgres + # output stats as JSON container stats --format json --no-stream web ``` diff --git a/docs/how-to.md b/docs/how-to.md index 22069994..07a3c0dc 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -419,6 +419,32 @@ You can also output statistics in JSON format for scripting: - **Block I/O**: Disk bytes read and written. - **Pids**: Number of processes running in the container. +### Detailed I/O Performance Statistics + +For database workloads, build systems, or performance-sensitive applications, use the `--io` flag to display detailed I/O metrics: + +```console +% container stats --io --no-stream postgres +CONTAINER READ/s WRITE/s LAT(ms) FSYNC(ms) QD DIRTY BACKEND +postgres 280MB 195MB 4.8 1.4 1 2.1% virtio +``` + +This mode provides: + +- **READ/s / WRITE/s**: Read and write throughput per second +- **LAT(ms)**: Average I/O latency in milliseconds (helps identify slow disk operations) +- **FSYNC(ms)**: Average fsync latency (critical for database durability) +- **QD**: I/O queue depth (indicates I/O concurrency) +- **DIRTY**: Percentage of dirty pages waiting to be written +- **BACKEND**: Storage backend type (virtio, apfs, ext4, etc.) + +**Use cases for I/O statistics:** + +- **Database performance tuning**: Monitor fsync latency and queue depth for Postgres, MySQL, MongoDB +- **Build system optimization**: Track I/O patterns during Docker builds or compilation +- **Diagnosing bottlenecks**: Identify whether slowness is due to CPU, memory, or disk I/O +- **Capacity planning**: Understand actual I/O requirements for workload sizing + ## Expose virtualization capabilities to a container > [!NOTE] From b3c45a3b3a9dae431ecfbce65aed025ab882ba4f Mon Sep 17 00:00:00 2001 From: Ronit Sabhaya Date: Sun, 14 Dec 2025 13:40:06 -0600 Subject: [PATCH 11/11] style: apply code formatting --- .../ContainerClient/Core/ContainerStats.swift | 2 +- .../Container/ContainerStats.swift | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/ContainerClient/Core/ContainerStats.swift b/Sources/ContainerClient/Core/ContainerStats.swift index a1f2eb6a..6abbb90d 100644 --- a/Sources/ContainerClient/Core/ContainerStats.swift +++ b/Sources/ContainerClient/Core/ContainerStats.swift @@ -36,7 +36,7 @@ public struct ContainerStats: Sendable, Codable { public var blockWriteBytes: UInt64 /// Number of processes in the container public var numProcesses: UInt64 - + // Extended I/O metrics /// Read operations per second public var readOpsPerSec: Double? diff --git a/Sources/ContainerCommands/Container/ContainerStats.swift b/Sources/ContainerCommands/Container/ContainerStats.swift index e2d19ab4..0403cdb4 100644 --- a/Sources/ContainerCommands/Container/ContainerStats.swift +++ b/Sources/ContainerCommands/Container/ContainerStats.swift @@ -34,7 +34,7 @@ extension Application { @Flag(name: .long, help: "Disable streaming stats and only pull the first result") var noStream = false - + @Flag(name: .long, help: "Display detailed I/O statistics (IOPS, latency, fsync, queue depth)") var io = false @@ -234,7 +234,7 @@ extension Application { printDefaultStatsTable(statsData) } } - + private func printDefaultStatsTable(_ statsData: [StatsSnapshot]) { let header = [["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]] var rows = header @@ -263,28 +263,28 @@ extension Application { let formatter = TableOutput(rows: rows) print(formatter.format()) } - + private func printIOStatsTable(_ statsData: [StatsSnapshot]) { let header = [["CONTAINER", "READ/s", "WRITE/s", "LAT(ms)", "FSYNC(ms)", "QD", "DIRTY", "BACKEND"]] var rows = header for snapshot in statsData { let stats2 = snapshot.stats2 - + // Calculate throughput from bytes (convert to MB/s) let readMBps = Self.formatThroughput(stats2.blockReadBytes) let writeMBps = Self.formatThroughput(stats2.blockWriteBytes) - + // Format latency metrics let latency = stats2.readLatencyMs != nil ? String(format: "%.1f", stats2.readLatencyMs!) : "N/A" let fsyncLatency = stats2.fsyncLatencyMs != nil ? String(format: "%.1f", stats2.fsyncLatencyMs!) : "N/A" - + // Format queue depth let queueDepth = stats2.queueDepth != nil ? "\(stats2.queueDepth!)" : "N/A" - + // Format dirty pages percentage let dirty = stats2.dirtyPagesPercent != nil ? String(format: "%.1f%%", stats2.dirtyPagesPercent!) : "N/A" - + // Storage backend let backend = stats2.storageBackend ?? "unknown" @@ -296,7 +296,7 @@ extension Application { fsyncLatency, queueDepth, dirty, - backend + backend, ]) } @@ -304,12 +304,12 @@ extension Application { let formatter = TableOutput(rows: rows) print(formatter.format()) } - + static func formatThroughput(_ bytes: UInt64) -> String { let mb = 1024.0 * 1024.0 let kb = 1024.0 let value = Double(bytes) - + if value >= mb { return String(format: "%.0fMB", value / mb) } else if value >= kb {