[OPIK-7087] [DOCS] docs: add writing-visual-tests skill #13310
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: 🗂️ Auto Label PR | |
| on: | |
| pull_request_target: | |
| types: [opened, reopened, synchronize, ready_for_review] | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| labeler: | |
| name: PR Labeler - Apply and Sync Labels | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 1 | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Apply and Sync Labels | |
| uses: actions/labeler@v6 | |
| with: | |
| sync-labels: true # Remove labels if the file paths no longer match | |
| configuration-path: .github/labeler.yml | |
| size-labeler: | |
| name: Label PR by size | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 2 | |
| # Run after the path-based labeler, never alongside it. actions/labeler's | |
| # sync-labels sweep writes an authoritative label set from a snapshot taken | |
| # when it started; if it overlaps the size step it drops the size label it | |
| # never saw. Ordering the size step after the sync closes that window. | |
| # | |
| # needs orders it after labeler; !cancelled() lets it still run when labeler | |
| # fails (e.g. a flaky action download) so size labeling isn't coupled to the | |
| # path labeler's success, while still skipping when the run is superseded. | |
| needs: labeler | |
| if: ${{ !cancelled() }} | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| steps: | |
| - name: Compute size and apply label | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const pr = context.payload.pull_request; | |
| const prNumber = pr.number; | |
| // Uniform charcoal chip for every size label so the size metadata reads as one | |
| // recessive family and doesn't compete with semantic labels (bug, Frontend, …) on | |
| // a busy PR list. The cool -> hot ramp lives entirely in the per-bucket emoji | |
| // (🔵 smallest -> 🔴 largest), which keeps its crisp shape on a neutral background. | |
| // maxSize is the inclusive upper bound of changed LOC (additions + deletions) for | |
| // the bucket; the last entry (null) catches everything above it. | |
| const CHIP_COLOR = "2d3139"; | |
| const BUCKETS = [ | |
| { name: "🔵 size/XS", color: CHIP_COLOR, maxSize: 19 }, | |
| { name: "🟢 size/S", color: CHIP_COLOR, maxSize: 100 }, | |
| { name: "🟡 size/M", color: CHIP_COLOR, maxSize: 300 }, | |
| { name: "🟠 size/L", color: CHIP_COLOR, maxSize: 600 }, | |
| { name: "🔴 size/XL", color: CHIP_COLOR, maxSize: null }, | |
| ]; | |
| const MANAGED_LABELS = BUCKETS.map((b) => b.name); | |
| // Lockfiles and generated clients dwarf the human-reviewed diff, so exclude them | |
| // from the count — otherwise a lockfile bump mislabels a tiny PR as XL. | |
| const IGNORE_GLOBS = [ | |
| "**/package-lock.json", | |
| "**/yarn.lock", | |
| "**/pnpm-lock.yaml", | |
| "**/poetry.lock", | |
| "**/uv.lock", | |
| "sdks/python/src/opik/rest_api/**", | |
| "sdks/typescript/src/opik/rest_api/**", | |
| "**/*.snap", | |
| "**/*.svg", | |
| "**/*.png", | |
| ]; | |
| const globToRegExp = (glob) => { | |
| let re = ""; | |
| for (let i = 0; i < glob.length; i++) { | |
| if (glob.startsWith("**/", i)) { re += "(?:[^/]+/)*"; i += 2; continue; } | |
| if (glob.startsWith("**", i)) { re += ".*"; i += 1; continue; } | |
| const c = glob[i]; | |
| if (c === "*") { re += "[^/]*"; continue; } | |
| if (".+^${}()|[]\\".includes(c)) { re += "\\" + c; continue; } | |
| re += c; | |
| } | |
| return new RegExp("^" + re + "$"); | |
| }; | |
| const ignoreRes = IGNORE_GLOBS.map(globToRegExp); | |
| const isIgnored = (path) => ignoreRes.some((re) => re.test(path)); | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| per_page: 100, | |
| }); | |
| const total = files | |
| .filter((f) => !isIgnored(f.filename)) | |
| .reduce((sum, f) => sum + f.additions + f.deletions, 0); | |
| const bucket = | |
| BUCKETS.find((b) => b.maxSize !== null && total <= b.maxSize) || | |
| BUCKETS[BUCKETS.length - 1]; | |
| core.info(`PR #${prNumber}: ${total} changed LOC (excluding ignored files) -> ${bucket.name}`); | |
| // Keep the label's color/emoji authoritative regardless of how it was first created. | |
| try { | |
| const { data: existing } = await github.rest.issues.getLabel({ | |
| owner, | |
| repo, | |
| name: bucket.name, | |
| }); | |
| if (existing.color !== bucket.color) { | |
| await github.rest.issues.updateLabel({ | |
| owner, | |
| repo, | |
| name: bucket.name, | |
| color: bucket.color, | |
| }); | |
| core.info(`Updated label color: ${bucket.name} -> #${bucket.color}`); | |
| } | |
| } catch (err) { | |
| if (err.status !== 404) throw err; | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name: bucket.name, | |
| color: bucket.color, | |
| }); | |
| core.info(`Created label: ${bucket.name} (#${bucket.color})`); | |
| } | |
| // Read the live label set rather than the event payload, which is a stale snapshot | |
| // from when the event fired and predates the labeler job's sync. | |
| const live = await github.paginate(github.rest.issues.listLabelsOnIssue, { | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| }); | |
| const current = live.map((l) => l.name); | |
| core.info(`PR #${prNumber} live labels: [${current.join(", ")}]`); | |
| // Reconcile with an incremental delta that touches only the managed size buckets: | |
| // remove any bucket that isn't the target, add the target if missing. The needs: | |
| // labeler ordering already serializes this against the path labeler, so there is no | |
| // concurrent writer to interleave with — and unlike setLabels, a delta never rewrites | |
| // the full set, so a label added by anyone else in the meantime is left untouched. | |
| for (const stale of current.filter( | |
| (name) => MANAGED_LABELS.includes(name) && name !== bucket.name | |
| )) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| name: stale, | |
| }); | |
| core.info(`Removed stale size label: ${stale}`); | |
| } catch (err) { | |
| // The label may have been removed by someone else between the read and now; | |
| // a 404 is benign. Swallow it so the loop and the add below still run. | |
| if (err.status !== 404) throw err; | |
| core.info(`Stale size label already gone (404): ${stale}`); | |
| } | |
| } | |
| if (!current.includes(bucket.name)) { | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| labels: [bucket.name], | |
| }); | |
| core.info(`Added size label: ${bucket.name}`); | |
| } else { | |
| core.info(`Size label already present, no add needed: ${bucket.name}`); | |
| } |