diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e6d7c3b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "name": "Python 3", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + "postCreateCommand": "pip3 install --user -r requirements.txt" +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..75f81e3 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +AUTH_TOKEN= diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..e269f4d --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,41 @@ +on: + release: + types: [published] + +name: Publish + +jobs: + publish: + name: Publish + runs-on: ubuntu-22.04 + timeout-minutes: 10 + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + uses: docker/metadata-action@v5 + with: + images: ${{ github.repository }} + id: meta + + - name: Build image + uses: docker/build-push-action@v5 + with: + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: | + linux/386 + linux/amd64 + linux/arm64 diff --git a/.github/workflows/test-build.yaml b/.github/workflows/test-build.yaml new file mode 100644 index 0000000..4a717d6 --- /dev/null +++ b/.github/workflows/test-build.yaml @@ -0,0 +1,31 @@ +on: + push: + branches: + - main + pull_request: + +name: Test build + +jobs: + test-build: + name: Test build + runs-on: ubuntu-22.04 + timeout-minutes: 10 + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v5 + with: + push: false + load: true + tags: ${{ github.repository }}:${{ github.sha }} + + - name: Test run + run: docker run --rm ${GITHUB_REPOSITORY}:${GITHUB_SHA} --help diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cef73e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +.env.* +!.env.example diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc95ea0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12 + +COPY requirements.txt / +RUN pip install -r requirements.txt + +COPY main.py / + +ENTRYPOINT ["python", "/main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..71d3bc3 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Jira Timesheet PDF + +Simple script to generate monthly timesheets based on Jira's worklog. + +Adapted from [jordanjambazov/jira-timesheet-pdf](https://github.com/jordanjambazov/jira-timesheet-pdf). + +## Usage + +First things first, you'll need an API token to fetch worklogs from Jira. +Hence, browse to https://id.atlassian.com/manage-profile/security/api-tokens, +create one and paste it to a `.env` (copied from `.env.example`): + +```env +AUTH_TOKEN=... +``` + +That's it... Just run it... + +### Build image + +```sh +docker build -t jira-timesheet-pdf . + +# Have a look at the help +docker run --rm jira-timesheet-pdf --help +``` + +### Just run it + +You'll need a bit of docker volumes kung-fu, otherwise the PDF will be lost with +the container: + +```sh +docker run --rm --env-file=.env -v "$(pwd):/app" -w /app -u $(id -u):$(id -g) jira-timesheet-pdf \ + --server=example.atlassian.net \ + --auth-email=user@example.com \ + --user='John Doe' \ + --yyyy-mm 2024-01 +``` + +If you don't like docker volumes kung-fu, consider stdout kung-fu: + +```sh +docker run --rm --env-file=.env jira-timesheet-pdf \ + --output=/dev/stdout \ + --server=example.atlassian.net \ + --auth-email=user@example.com \ + --user='John Doe' \ + --yyyy-mm 2024-01 \ + > timesheet.pdf +``` + +### Example output + +

+ Example timesheet +

diff --git a/main.py b/main.py new file mode 100755 index 0000000..6016021 --- /dev/null +++ b/main.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import argparse +import calendar +import datetime +import json +import logging +import os +import sys +import textwrap + +import jira +import reportlab, reportlab.platypus + +JIRA_DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z' + + +def generate_report( + output: str, + title: str, + date_from: datetime.date, + date_to: datetime.date, + worklogs_by_issue: dict[jira.resources.Issue, list[jira.resources.Worklog]], +) -> reportlab.platypus.doctemplate.BaseDocTemplate: + doc = reportlab.platypus.SimpleDocTemplate( + output, + pagesize=reportlab.lib.pagesizes.landscape(reportlab.lib.pagesizes.A4), + ) + + stylesheet = reportlab.lib.styles.getSampleStyleSheet() + + elements = [] + + elements.append( + reportlab.platypus.Paragraph( + title, + reportlab.lib.styles.ParagraphStyle( + '', + parent=stylesheet['Heading2'], + alignment=reportlab.lib.enums.TA_CENTER, + ), + ) + ) + + style = [ + # Grid border + ('GRID', (+0, +0), (-1, -1), 0.2, reportlab.lib.colors.lightgrey), + # Horizontally center everything + ('ALIGN', (+0, +0), (-1, -1), 'CENTER'), + # Vertically center everything + ('VALIGN', (+0, +0), (-1, -1), 'MIDDLE'), + # Primary font + ('FONT', (+0, +0), (-1, -1), 'Helvetica', 8, 8), + # Make first column (issue descriptions) bold and left-aligned + ('ALIGN', (+0, +0), (+0, -1), 'LEFT'), + ('FONT', (+0, +0), (+0, -1), 'Helvetica-Bold', 8, 8), + # Make first row (days) bold + ('FONT', (0, 0), (-1, 0), 'Helvetica-Bold', 8, 8), + ] + + data = [ + ['', ], + # ['Issue 1', ], + # ['Issue 2', ], + # ... + ] + + for day in range((date_to - date_from).days + 1): + current_date = (date_from + datetime.timedelta(days=day)) + if current_date.weekday() >= 5: + style.append( + # Darker weekend background color + ('BACKGROUND', (day+1, +0), (day+1, -1), reportlab.lib.colors.whitesmoke), + ) + + data[0].append( + current_date.strftime("%d") + "\n" + current_date.strftime("%a")[0] + ) + + for issue, worklogs in worklogs_by_issue.items(): + data.append([textwrap.fill(f'{issue.key} - {issue.fields.summary}', 50), ]) + + for day in range((date_to - date_from).days + 1): + current_date = (date_from + datetime.timedelta(days=day)) + if current_date.weekday() >= 5: + style.append( + # Darker weekend background color + ('BACKGROUND', (day+1, +0), (day+1, -1), reportlab.lib.colors.whitesmoke), + ) + + time_spent_seconds = sum( + worklog.timeSpentSeconds + for worklog in worklogs + if datetime.datetime.strptime(worklog.started, JIRA_DATETIME_FORMAT).date() == current_date + ) + + data[-1].append( + f'{time_spent_seconds / 3600:.1f}' if time_spent_seconds > 0 else '' + ) + + elements.append( + reportlab.platypus.Table( + data, + style=style, + colWidths=[None] + [6*reportlab.lib.pagesizes.mm] * (len(data[0]) - 1), + ) + ) + + total_spent_seconds = sum( + sum(worklog.timeSpentSeconds for worklog in worklogs) + for worklogs in worklogs_by_issue.values() + ) + + elements.append( + reportlab.platypus.Paragraph( + f'Total Hours: {total_spent_seconds / 3600:.2f}', + reportlab.lib.styles.ParagraphStyle( + '', + parent=stylesheet['BodyText'], + alignment=reportlab.lib.enums.TA_CENTER, + ), + ) + ) + + doc.build(elements) + + return doc + + +def get_worklogs_by_issue( + client: jira.JIRA, + user: str, + date_from: datetime.date, + date_to: datetime.date, +) -> dict[jira.resources.Issue, list[jira.resources.Worklog]]: + issues = client.search_issues(f''' + worklogAuthor = '%s' + AND worklogDate >= {json.dumps(date_from.strftime('%Y-%m-%d'))} + AND worklogDate <= {json.dumps(date_to.strftime('%Y-%m-%d'))} + ORDER BY created ASC + ''' % user.replace("'", r"\'")) + + logging.info(f'Found {len(issues)} issues') + + worklogs_by_issue: dict[jira.resources.Issue, list[jira.resources.Worklog]] = {} + + for issue in issues: + worklogs = client.worklogs(issue.key) + logging.info(f'Issue {issue}: found {len(worklogs)} worklogs') + + worklogs = list(filter( + lambda worklog: worklog.author.displayName == user, + worklogs, + )) + logging.info(f'Issue {issue}: found {len(worklogs)} worklogs by user {user}') + + worklogs = list(filter( + lambda worklog: date_from <= datetime.datetime.strptime(worklog.started, JIRA_DATETIME_FORMAT).date() <= date_to, + worklogs, + )) + logging.info(f'Issue {issue}: found {len(worklogs)} worklogs in date range {date_from} - {date_to}') + + worklogs_by_issue[issue] = worklogs + + return worklogs_by_issue + + +def main(args) -> None: + yyyy_mm = datetime.datetime.strptime(args.yyyy_mm, '%Y-%m') + year, month = yyyy_mm.year, yyyy_mm.month + month_start = datetime.date(year, month, 1) + month_last = datetime.date(year, month, calendar.monthrange(year, month)[1]) + + logging.info(f'Generating worklog report from {month_start} to {month_last} for user {args.user}') + + worklogs_by_issue = get_worklogs_by_issue( + jira.JIRA(f'https://{args.server}', basic_auth=(args.auth_email, args.auth_token)), + args.user, + month_start, + month_last, + ) + + generate_report( + args.output or month_start.strftime('timesheet-%Y-%m.pdf'), + month_start.strftime('%B %Y'), + month_start, + month_last, + worklogs_by_issue, + ) + + + +if __name__ == "__main__": + logging.basicConfig(stream=sys.stderr, level=logging.INFO) + + parser = argparse.ArgumentParser() + parser.add_argument( + '--server', + type=str, + required=True, + help='The Jira server domain name; e.g. example.atlassian.net', + ) + parser.add_argument( + '--auth-email', + type=str, + required=True, + help='The email for authenticating to the Jira API', + ) + parser.add_argument( + '--auth-token', + type=str, + default=os.environ.get('AUTH_TOKEN'), + help='The token for authenticating to the Jira API (defaults to AUTH_TOKEN environment variable)', + ) + parser.add_argument( + '--yyyy-mm', + type=str, + default=datetime.datetime.now().strftime('%Y-%m'), + help='The YYYY-MM formatted month for which to generate the report (defaults to current month)', + ) + parser.add_argument( + '--user', + type=str, + required=True, + help='The display name of the user for which to generate the report; e.g. John Doe', + ) + parser.add_argument( + '--output', + type=str, + default=None, + help='The file name where to output the report (defaults to timesheet-YYYY-MM.pdf)', + ) + + args = parser.parse_args() + + main(args) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3b74792 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +jira~=3.6 +reportlab~=4.0