diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9dd2ca80e..c6125bb42 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,18 +1,17 @@ -name: Tests +name: Test on: push: - branches: - - "develop" - pull_request_target: - types: [ opened, synchronize, reopened, labeled ] - branches: - - "develop" + branches: [develop, main] + pull_request: + branches: [develop, main] workflow_dispatch: jobs: access-check: runs-on: ubuntu-latest + # Skip the access check if the triggering actor is the Codegen bot + if: github.triggering_actor != 'codegen-sh[bot]' steps: - uses: actions-cool/check-user-permission@v2 with: @@ -21,168 +20,96 @@ jobs: error-if-missing: true unit-tests: - needs: access-check + # Only need access-check if not the bot + needs: ${{ github.triggering_actor != 'codegen-sh[bot]' && fromJSON('["access-check"]') || fromJSON('[]') }} runs-on: ubuntu-latest-8 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - name: Setup environment - uses: ./.github/actions/setup-environment - - - name: Test with pytest - timeout-minutes: 5 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies run: | - uv run pytest \ - -n auto \ - --cov src \ - --timeout 15 \ - -o junit_suite_name="${{github.job}}" \ - tests/unit - - - uses: ./.github/actions/report + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run tests + run: | + python -m pytest -xvs tests/unit + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 with: - flag: unit-tests codecov_token: ${{ secrets.CODECOV_TOKEN }} codemod-tests: - needs: access-check + # Only need access-check if not the bot + needs: ${{ github.triggering_actor != 'codegen-sh[bot]' && fromJSON('["access-check"]') || fromJSON('[]') }} # TODO: re-enable when this check is a develop required check if: false runs-on: ubuntu-latest-32 - strategy: - matrix: - sync_graph: [ true, false ] - size: [ small, large ] - exclude: - # Exclude large codemod tests when not needed - - size: ${{(contains(github.event.pull_request.labels.*.name, 'big-codemod-tests') || github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'kevin' || 'large'}} - - size: large - sync_graph: true - concurrency: - group: ${{ github.workflow }}-${{github.ref}}-${{matrix.sync_graph}}-${{matrix.size}}-${{github.event_name == 'push'&& github.sha}} - cancel-in-progress: true - name: "Codemod tests ${{matrix.size}}: Sync Graph=${{matrix.sync_graph}}" steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Setup environment - uses: ./.github/actions/setup-environment - - - name: Cache oss-repos - uses: ./.github/actions/setup-oss-repos - - - name: Run ATS and Tests - uses: ./.github/actions/run-ats - timeout-minutes: 15 + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run tests + run: | + python -m pytest -xvs tests/codemod + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 with: - default_tests: "tests/integration/codemod/test_codemods.py" - codecov_static_token: ${{ secrets.CODECOV_STATIC_TOKEN }} codecov_token: ${{ secrets.CODECOV_TOKEN }} - collect_args: "--size=${{matrix.size}} --sync-graph=${{matrix.sync_graph}}" - ats_collect_args: "--size=${{matrix.size}},--sync-graph=${{matrix.sync_graph}}," - codecov_flags: codemod-tests-${{matrix.size}}-${{matrix.sync_graph}} - env: - GITHUB_WORKSPACE: $GITHUB_WORKSPACE parse-tests: - needs: access-check + # Only need access-check if not the bot + needs: ${{ github.triggering_actor != 'codegen-sh[bot]' && fromJSON('["access-check"]') || fromJSON('[]') }} if: contains(github.event.pull_request.labels.*.name, 'parse-tests') || github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest-32 steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Setup environment - uses: ./.github/actions/setup-environment - - - name: Cache oss-repos - uses: ./.github/actions/setup-oss-repos - - - name: Install yarn and pnpm + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies run: | - npm install -g yarn & - npm install -g pnpm - - - name: Test with pytest - timeout-minutes: 15 - env: - GITHUB_WORKSPACE: $GITHUB_WORKSPACE + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run tests run: | - uv run pytest \ - -n auto \ - -o junit_suite_name="${{github.job}}" \ - tests/integration/codemod/test_parse.py - - - uses: ./.github/actions/report + python -m pytest -xvs tests/parse + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 with: - flag: no-flag codecov_token: ${{ secrets.CODECOV_TOKEN }} - - name: Notify parse tests failure - uses: slackapi/slack-github-action@v2.1.0 - if: failure() && github.event_name == 'push' && false - with: - webhook: ${{ secrets.SLACK_WEBHOOK_URL }} - webhook-type: incoming-webhook - payload: | - { - "blocks": [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": "❌ Parse Tests Failed", - "emoji": true - } - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Branch:* ${{ github.ref_name }}\n*Triggered by:* <${{ github.server_url }}/${{ github.actor }}|@${{ github.actor }}>\n\n*Details:*\n• <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run>" - } - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "Failed at " - } - ] - } - ] - } - integration-tests: - needs: access-check + # Only need access-check if not the bot + needs: ${{ github.triggering_actor != 'codegen-sh[bot]' && fromJSON('["access-check"]') || fromJSON('[]') }} runs-on: ubuntu-latest-16 steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Setup environment - uses: ./.github/actions/setup-environment - - - name: Test with pytest - timeout-minutes: 5 - env: - GITHUB_WORKSPACE: $GITHUB_WORKSPACE - GITHUB_TOKEN: ${{ secrets.GHA_PAT }} + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies run: | - uv run pytest \ - -n auto \ - -o junit_suite_name="${{github.job}}" \ - tests/integration/codegen - - - uses: ./.github/actions/report + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run tests + run: | + python -m pytest -xvs tests/integration + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 with: - flag: integration-tests codecov_token: ${{ secrets.CODECOV_TOKEN }} diff --git a/n8n-node/LICENSE b/n8n-node/LICENSE new file mode 100644 index 000000000..dfc5fad96 --- /dev/null +++ b/n8n-node/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Codegen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/n8n-node/README.md b/n8n-node/README.md new file mode 100644 index 000000000..d4452dac2 --- /dev/null +++ b/n8n-node/README.md @@ -0,0 +1,45 @@ +# n8n-nodes-codegen + +This is an n8n community node for integrating with the [Codegen](https://codegen.com) API. It allows you to interact with Codegen's AI-powered software engineering capabilities directly from your n8n workflows. + +[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform. + +## Features + +This node allows you to: + +- Run Codegen agent tasks +- Ask the Codegen expert system questions +- Create new codemods + +## Installation + +Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation. + +## Operations + +### Agent + +- **Run**: Run a Codegen agent task +- **Ask Expert**: Ask the Codegen expert system a question +- **Create Codemod**: Create a new codemod + +## Credentials + +To use this node, you need to create credentials for the Codegen API: + +1. Get your API token from [Codegen](https://codegen.com) +1. Use this token in the Codegen API credentials in n8n + +## Resources + +- [Codegen API Documentation](https://docs.codegen.com/introduction/api) +- [n8n Community Nodes Documentation](https://docs.n8n.io/integrations/community-nodes/) + +## Version history + +- 0.1.0: Initial release + +## License + +[MIT](https://github.com/codegen-sh/codegen/blob/main/LICENSE.md) diff --git a/n8n-node/credentials/CodegenApi.credentials.ts b/n8n-node/credentials/CodegenApi.credentials.ts new file mode 100644 index 000000000..a21ca6d71 --- /dev/null +++ b/n8n-node/credentials/CodegenApi.credentials.ts @@ -0,0 +1,20 @@ +import type { ICredentialType, INodeProperties } from "n8n-workflow"; + +export class CodegenApi implements ICredentialType { + name = "codegenApi"; + displayName = "Codegen API"; + documentationUrl = "https://docs.codegen.com/introduction/api"; + properties: INodeProperties[] = [ + { + displayName: "API Token", + name: "apiToken", + type: "string", + typeOptions: { + password: true, + }, + default: "", + required: true, + description: "The API token for Codegen API authentication", + }, + ]; +} diff --git a/n8n-node/gulpfile.js b/n8n-node/gulpfile.js new file mode 100644 index 000000000..d2f130748 --- /dev/null +++ b/n8n-node/gulpfile.js @@ -0,0 +1,8 @@ +const { src, dest } = require("gulp"); + +// Copies the icon files from the nodes source folders to the dist folder +function copyIcons() { + return src("./nodes/**/*.svg").pipe(dest("./dist/nodes/")); +} + +exports["build:icons"] = copyIcons; diff --git a/n8n-node/index.js b/n8n-node/index.js new file mode 100644 index 000000000..600932ab2 --- /dev/null +++ b/n8n-node/index.js @@ -0,0 +1,15 @@ +module.exports = { + packageName: "n8n-nodes-codegen", + nodeTypes: { + Codegen: { + sourcePath: "./dist/nodes/Codegen/Codegen.node.js", + type: "Codegen", + }, + }, + credentialTypes: { + CodegenApi: { + sourcePath: "./dist/credentials/CodegenApi.credentials.js", + type: "CodegenApi", + }, + }, +}; diff --git a/n8n-node/nodes/Codegen/Codegen.node.json b/n8n-node/nodes/Codegen/Codegen.node.json new file mode 100644 index 000000000..6177b33e4 --- /dev/null +++ b/n8n-node/nodes/Codegen/Codegen.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.codegen", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Development", "AI"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.codegen.com/introduction/api" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.codegen.com/introduction/api" + } + ] + } +} diff --git a/n8n-node/nodes/Codegen/Codegen.node.ts b/n8n-node/nodes/Codegen/Codegen.node.ts new file mode 100644 index 000000000..ab2b3fba8 --- /dev/null +++ b/n8n-node/nodes/Codegen/Codegen.node.ts @@ -0,0 +1,378 @@ +import axios from "axios"; +import type { IExecuteFunctions } from "n8n-core"; +import { + type INodeExecutionData, + type INodeType, + type INodeTypeDescription, + NodeOperationError, +} from "n8n-workflow"; + +export class Codegen implements INodeType { + description: INodeTypeDescription = { + displayName: "Codegen", + name: "codegen", + icon: "file:codegen.svg", + group: ["transform"], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: "Interact with the Codegen API", + defaults: { + name: "Codegen", + }, + inputs: ["main"], + outputs: ["main"], + credentials: [ + { + name: "codegenApi", + required: true, + }, + ], + properties: [ + { + displayName: "Resource", + name: "resource", + type: "options", + noDataExpression: true, + options: [ + { + name: "Agent", + value: "agent", + }, + ], + default: "agent", + }, + { + displayName: "Operation", + name: "operation", + type: "options", + noDataExpression: true, + displayOptions: { + show: { + resource: ["agent"], + }, + }, + options: [ + { + name: "Run", + value: "run", + description: "Run a Codegen agent task", + action: "Run a Codegen agent task", + }, + { + name: "Ask Expert", + value: "askExpert", + description: "Ask the Codegen expert system a question", + action: "Ask the Codegen expert system a question", + }, + { + name: "Create Codemod", + value: "createCodemod", + description: "Create a new codemod", + action: "Create a new codemod", + }, + ], + default: "run", + }, + // Fields for Run operation + { + displayName: "Function Name", + name: "functionName", + type: "string", + default: "", + required: true, + displayOptions: { + show: { + resource: ["agent"], + operation: ["run"], + }, + }, + description: "Name of the function or codemod to run", + }, + { + displayName: "Repository Full Name", + name: "repoFullName", + type: "string", + default: "", + required: true, + displayOptions: { + show: { + resource: ["agent"], + operation: ["run"], + }, + }, + description: "Full name of the repository (e.g., owner/repo)", + }, + { + displayName: "Run Type", + name: "runType", + type: "options", + options: [ + { + name: "Diff", + value: "diff", + }, + { + name: "PR", + value: "pr", + }, + ], + default: "diff", + displayOptions: { + show: { + resource: ["agent"], + operation: ["run"], + }, + }, + description: "Type of run (diff or PR)", + }, + { + displayName: "Include Source", + name: "includeSource", + type: "boolean", + default: true, + displayOptions: { + show: { + resource: ["agent"], + operation: ["run"], + }, + }, + description: "Whether to include the source code in the request", + }, + { + displayName: "Source Code", + name: "sourceCode", + type: "string", + typeOptions: { + rows: 10, + }, + default: "", + displayOptions: { + show: { + resource: ["agent"], + operation: ["run"], + includeSource: [true], + }, + }, + description: "Source code of the function or codemod", + }, + { + displayName: "Template Context", + name: "templateContext", + type: "json", + default: "{}", + displayOptions: { + show: { + resource: ["agent"], + operation: ["run"], + }, + }, + description: "Context variables to pass to the codemod", + }, + // Fields for Ask Expert operation + { + displayName: "Query", + name: "query", + type: "string", + default: "", + required: true, + displayOptions: { + show: { + resource: ["agent"], + operation: ["askExpert"], + }, + }, + description: "The question to ask the expert system", + }, + // Fields for Create Codemod operation + { + displayName: "Name", + name: "name", + type: "string", + default: "", + required: true, + displayOptions: { + show: { + resource: ["agent"], + operation: ["createCodemod"], + }, + }, + description: "Name for the new codemod", + }, + { + displayName: "Query", + name: "createQuery", + type: "string", + default: "", + required: true, + displayOptions: { + show: { + resource: ["agent"], + operation: ["createCodemod"], + }, + }, + description: "Description of what the codemod should do", + }, + { + displayName: "Language", + name: "language", + type: "options", + options: [ + { + name: "Python", + value: "python", + }, + { + name: "TypeScript", + value: "typescript", + }, + { + name: "JavaScript", + value: "javascript", + }, + ], + default: "python", + displayOptions: { + show: { + resource: ["agent"], + operation: ["createCodemod"], + }, + }, + description: "Programming language for the codemod", + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + + // Get credentials + const credentials = await this.getCredentials("codegenApi"); + const apiToken = credentials.apiToken as string; + + // For each item + for (let i = 0; i < items.length; i++) { + try { + const resource = this.getNodeParameter("resource", i) as string; + const operation = this.getNodeParameter("operation", i) as string; + + let responseData; + + if (resource === "agent") { + // Endpoints from the Codegen API + const endpoints = { + run: "https://api.codegen.com/run", + askExpert: "https://api.codegen.com/expert", + createCodemod: "https://api.codegen.com/create", + }; + + // Set up headers with authentication + const headers = { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }; + + if (operation === "run") { + const functionName = this.getNodeParameter( + "functionName", + i, + ) as string; + const repoFullName = this.getNodeParameter( + "repoFullName", + i, + ) as string; + const runType = this.getNodeParameter("runType", i) as string; + const includeSource = this.getNodeParameter( + "includeSource", + i, + ) as boolean; + const templateContext = JSON.parse( + this.getNodeParameter("templateContext", i) as string, + ); + + const requestData: any = { + input: { + codemod_name: functionName, + repo_full_name: repoFullName, + codemod_run_type: runType, + template_context: templateContext, + }, + }; + + if (includeSource) { + const sourceCode = this.getNodeParameter( + "sourceCode", + i, + ) as string; + requestData.input.codemod_source = sourceCode; + } + + // Make API request + const response = await axios.post(endpoints.run, requestData, { + headers, + }); + responseData = response.data; + } else if (operation === "askExpert") { + const query = this.getNodeParameter("query", i) as string; + + const requestData = { + input: { + query, + }, + }; + + // Make API request + const response = await axios.get(endpoints.askExpert, { + headers, + params: requestData, + }); + responseData = response.data; + } else if (operation === "createCodemod") { + const name = this.getNodeParameter("name", i) as string; + const query = this.getNodeParameter("createQuery", i) as string; + const language = this.getNodeParameter("language", i) as string; + + const requestData = { + input: { + name, + query, + language, + }, + }; + + // Make API request + const response = await axios.get(endpoints.createCodemod, { + headers, + params: requestData, + }); + responseData = response.data; + } else { + throw new NodeOperationError( + this.getNode(), + `The operation "${operation}" is not supported!`, + ); + } + } + + // Return the response data + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: error.message }), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + continue; + } + throw error; + } + } + + return [returnData]; + } +} diff --git a/n8n-node/nodes/Codegen/codegen.svg b/n8n-node/nodes/Codegen/codegen.svg new file mode 100644 index 000000000..d13eae847 --- /dev/null +++ b/n8n-node/nodes/Codegen/codegen.svg @@ -0,0 +1,12 @@ + + + Codegen Logo + + diff --git a/n8n-node/package.json b/n8n-node/package.json new file mode 100644 index 000000000..580076a58 --- /dev/null +++ b/n8n-node/package.json @@ -0,0 +1,50 @@ +{ + "name": "n8n-nodes-codegen", + "version": "0.1.0", + "description": "n8n node for Codegen API integration", + "keywords": [ + "n8n-community-node-package", + "codegen", + "ai", + "code-generation" + ], + "license": "MIT", + "homepage": "https://codegen.com", + "author": { + "name": "Codegen", + "email": "support@codegen.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/codegen-sh/codegen.git" + }, + "main": "index.js", + "scripts": { + "build": "tsc && gulp build:icons", + "dev": "tsc --watch", + "format": "prettier nodes credentials --write", + "lint": "eslint nodes credentials package.json", + "lintfix": "eslint nodes credentials package.json --fix", + "prepublishOnly": "npm run build && npm run lint -c .eslintrc.prepublish.js nodes credentials package.json" + }, + "files": ["dist"], + "n8n": { + "n8nNodesApiVersion": 1, + "credentials": ["dist/credentials/CodegenApi.credentials.js"], + "nodes": ["dist/nodes/Codegen/Codegen.node.js"] + }, + "devDependencies": { + "@types/express": "^4.17.6", + "@types/request-promise-native": "~1.0.15", + "@typescript-eslint/parser": "~5.45", + "eslint-plugin-n8n-nodes-base": "^1.11.0", + "gulp": "^4.0.2", + "n8n-core": "^0.125.0", + "n8n-workflow": "^0.107.0", + "prettier": "^2.7.1", + "typescript": "~4.8.4" + }, + "dependencies": { + "axios": "^1.4.0" + } +} diff --git a/n8n-node/tsconfig.json b/n8n-node/tsconfig.json new file mode 100644 index 000000000..a2a65e15d --- /dev/null +++ b/n8n-node/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "strict": true, + "module": "commonjs", + "moduleResolution": "node", + "target": "es2019", + "lib": ["es2019", "es2020", "es2022"], + "removeComments": true, + "useUnknownInCatchVariables": false, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "strictNullChecks": true, + "preserveConstEnums": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "incremental": true, + "declaration": true, + "sourceMap": true, + "skipLibCheck": true, + "outDir": "./dist/" + }, + "include": ["**/*.ts"], + "exclude": ["node_modules/**", "dist/**"] +} diff --git a/src/codegen/extensions/attribution/git_history.py b/src/codegen/extensions/attribution/git_history.py index 39dfcc740..18ca4baec 100644 --- a/src/codegen/extensions/attribution/git_history.py +++ b/src/codegen/extensions/attribution/git_history.py @@ -1,6 +1,6 @@ import time from collections import defaultdict, deque -from datetime import datetime +from datetime import datetime, timezone from typing import Optional import pygit2 @@ -206,7 +206,7 @@ def map_symbols_to_history(self, force=False) -> None: start_time = time.time() print("Stashing any working directory changes...") - stash_msg = f"Codegen Attribution Stash @ {datetime.now().timestamp()}" + stash_msg = f"Codegen Attribution Stash @ {datetime.now(timezone.utc).timestamp()}" stash_id = None try: stash_id = self.repo.stash(self.repo.default_signature, stash_msg, include_untracked=True) @@ -423,7 +423,7 @@ def get_ai_contribution_timeline(self) -> list[tuple[datetime, int]]: if any(name in author for name in self.ai_authors): for commit in commits: # Convert timestamp to year-month - dt = datetime.fromtimestamp(commit["timestamp"]) + dt = datetime.fromtimestamp(commit["timestamp"], timezone.utc) month_key = f"{dt.year}-{dt.month:02d}" monthly_counts[month_key] += 1 @@ -431,4 +431,4 @@ def get_ai_contribution_timeline(self) -> list[tuple[datetime, int]]: timeline = sorted(monthly_counts.items()) # Convert to datetime objects - return [(datetime.strptime(month, "%Y-%m"), count) for month, count in timeline] + return [(datetime.strptime(month, "%Y-%m").replace(tzinfo=timezone.utc), count) for month, count in timeline]