Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
mcieno committed Jan 19, 2024
0 parents commit 68dce70
Show file tree
Hide file tree
Showing 9 changed files with 388 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Python 3",
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
"postCreateCommand": "pip3 install --user -r requirements.txt"
}
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
AUTH_TOKEN=
41 changes: 41 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions .github/workflows/test-build.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.env
.env.*
!.env.example
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM python:3.12

COPY requirements.txt /
RUN pip install -r requirements.txt

COPY main.py /

ENTRYPOINT ["python", "/main.py"]
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 \
[email protected] \
--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 \
[email protected] \
--user='John Doe' \
--yyyy-mm 2024-01 \
> timesheet.pdf
```

### Example output

<p align="center">
<img
width="1185"
alt="Example timesheet"
src="https://github.com/mcieno/jira-timesheet-pdf/assets/30049418/af2fa171-bc17-4b7c-8d28-03a13c8dbf5b"
/>
</p>
236 changes: 236 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
jira~=3.6
reportlab~=4.0

0 comments on commit 68dce70

Please sign in to comment.