πŸ“ˆ Plot: How long does it take for a PR to be merged? (Updated with issue stats)

  • Merged pull requests

    • by laurmaedje or reknih β€” Purple dots
    • by other human contributors β€” Blue dots
    • by dependabot β€” not drawn
  • Closed pull requests β€” Gray dots

  • Open pull requests β€” Green dots

For unmerged (closed/open) PRs, durations cannot be defined, and their y coordinates in the figure are random.

Python scripts

fetch_data.py

Requires GitHub CLI.

from pathlib import Path
from pprint import pprint
from subprocess import run
import json

file = Path("data.jsonl")

query = """
query($cursor: String) {
  repository(owner: "typst", name: "typst") {
    pullRequests(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: ASC}) {
      pageInfo {
        hasNextPage
        endCursor
      }
      nodes {
        number
        title
        state
        createdAt
        mergedAt
        author {
          login
        }
      }
    }
  }
}
""".strip()


page: dict | None = None
while page is None or page["hasNextPage"]:
    result = run(
        ["gh", "api", "graphql", "--raw-field", f"query={query}"]
        + (["--field", f"cursor={page['endCursor']}"] if page else []),
        text=True,
        check=True,
        capture_output=True,
    )
    data = json.loads(result.stdout)["data"]["repository"]["pullRequests"]
    page = data["pageInfo"]
    pulls = data["nodes"]

    pprint(page)

    for p in pulls:
        pprint(p)
        with file.open("a", encoding="utf-8") as f:
            f.write(json.dumps(p) + "\n")

plot_data.py

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "matplotlib",
# ]
# ///
import json
import random
from collections import deque
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path

from matplotlib.pyplot import subplots

random.seed(42)

file = Path("data.jsonl")


@dataclass
class Record:
    createdAt: datetime
    duration: timedelta


merged_pulls: deque[Record] = deque()
staff_pulls: deque[Record] = deque()
closed_pulls: deque[datetime] = deque()
open_pulls: deque[datetime] = deque()

staff = ["laurmaedje", "reknih"]
bot = ["dependabot"]

with file.open(encoding="utf-8") as f:
    while line := f.readline():
        row = json.loads(line)
        createdAt = datetime.fromisoformat(row["createdAt"])

        author: dict | None = row["author"]
        if author is not None and author["login"] in bot:
            # `author` might be `None`
            # See https://github.com/ghost and https://github.com/typst/typst/pull/1047
            continue

        match row["state"]:
            case "MERGED":
                mergedAt = datetime.fromisoformat(row["mergedAt"])
                duration = mergedAt - createdAt
                record = Record(createdAt=createdAt, duration=duration)

                if author is not None and author["login"] in staff:
                    staff_pulls.append(record)
                else:
                    merged_pulls.append(record)
            case "CLOSED":
                # This PR is not merged (closed or still open)
                closed_pulls.append(createdAt)
            case "OPEN":
                open_pulls.append(createdAt)


xticks = [
    # https://typst.app/blog/2023/january-update
    (datetime(2023, 1, 29), None),
    # https://typst.app/blog/2023/beta-oss-launch
    (datetime(2023, 3, 21), "public beta\n(2023-03-21)"),
    # https://typst.app/docs/changelog/0.1.0/
    (datetime(2023, 4, 4), None),
    # https://typst.app/docs/changelog/0.2.0/
    (datetime(2023, 4, 11), None),
    # https://typst.app/docs/changelog/0.3.0/
    (datetime(2023, 4, 26), None),
    # https://typst.app/docs/changelog/0.4.0/
    (datetime(2023, 5, 20), None),
    # https://typst.app/docs/changelog/0.5.0/
    (datetime(2023, 6, 9), None),
    # https://typst.app/docs/changelog/0.6.0/
    (datetime(2023, 6, 30), None),
    # https://typst.app/docs/changelog/0.7.0/
    (datetime(2023, 8, 7), "v0.7.0"),
    # https://typst.app/docs/changelog/0.8.0/
    (datetime(2023, 9, 13), None),
    # https://typst.app/docs/changelog/0.9.0/
    (datetime(2023, 10, 31), None),
    # https://typst.app/docs/changelog/0.10.0/
    (datetime(2023, 12, 4), "v0.10.0\n(2023-12-04)"),
    # https://typst.app/docs/changelog/0.11.0/
    (datetime(2024, 3, 15), None),
    # https://typst.app/docs/changelog/0.11.1/
    (datetime(2024, 5, 17), "v0.11.1"),
    # https://typst.app/blog/2024/typst-0.12
    (datetime(2024, 10, 18), "v0.12.0\n(2024-10-18)"),
    # https://typst.app/blog/2025/typst-0.13
    (datetime(2025, 2, 19), None),
    # https://typst.app/docs/changelog/0.13.1/
    (datetime(2025, 3, 7), "v0.13.1"),
    (datetime.now(), f"now\n({datetime.now().date().isoformat()})"),
]

duration_min = min(r.duration for r in merged_pulls)
duration_max = max(r.duration for r in merged_pulls)
yticks = [
    (duration_min, f"{duration_min / timedelta(seconds=1):.0f} sec. (min)"),
    (timedelta(days=1), "a day"),
    (timedelta(days=3), "3 days"),
    (timedelta(weeks=1), "a week"),
    (timedelta(weeks=2), "2 weeks"),
    (timedelta(weeks=4), "4 weeks"),
    (timedelta(days=30 * 2), "2 months"),
    (timedelta(days=30 * 4), "4 months"),
    (duration_max, f"{duration_max / timedelta(days=30):.2} mon. (max)"),
]

fig, ax = subplots(layout="constrained")
ax.set_title(
    "Pull requests to typst/typst",
    fontsize="xx-large",
    fontweight="black",
)
ax.set_xlabel(
    "Date of creation",
    fontsize="large",
    fontweight="bold",
)
ax.set_ylabel(
    "Duration before merged",
    fontsize="large",
    fontweight="bold",
)

merged_x = [r.createdAt for r in merged_pulls]
merged_y = [r.duration.total_seconds() for r in merged_pulls]
staff_x = [r.createdAt for r in staff_pulls]
staff_y = [r.duration.total_seconds() for r in staff_pulls]

ylim = (0, max(merged_y) * 3)

ax.scatter(merged_x, merged_y, marker="o", color="#239dad", alpha=0.5, s=8)
ax.scatter(staff_x, staff_y, marker="o", color="purple", alpha=0.5, s=8)

for pulls, color in [(closed_pulls, "gray"), (open_pulls, "green")]:
    ax.scatter(
        list(pulls),
        [
            random.uniform(
                yticks[-1][0].total_seconds() * 1.1,
                ylim[1] / 3 * 2.9,
            )
            for _ in pulls
        ],
        marker="o",
        color=color,
        alpha=0.5,
        s=8,
    )

ax.set_yscale("symlog", linthresh=yticks[3][0].total_seconds())
ax.set(
    xlim=(xticks[0][0], xticks[-1][0]),
    xticks=[t for t, s in xticks if s],
    xticklabels=[s for _t, s in xticks if s],
    ylim=ylim,
    yticks=[t[0].total_seconds() for t in yticks],
    yticklabels=(t[1] for t in yticks),
)
ax.set_xticks([t for t, s in xticks if not s], minor=True)

ax.grid(axis="x", which="major")
ax.grid(axis="x", which="minor", linewidth=1 / 4)
ax.grid(axis="y", which="major")

fig.savefig(Path(__file__).with_suffix(".png"))

data.jsonl.7z.pretended-to-be-csv.csv (70.0 KB)

I wish I could plot in typst, but typst doesn’t provide a reliable way to handle date+time objects (parsing ISO format, shifting timezone, etc.) yet…

12 Likes

Pull request durations (updated)

The PR merged after the longest duration is Add `title` element by jbirnick Β· Pull Request #5618 Β· typst/typst Β· GitHub.

I extend the meaning of duration so that unmerged pull requests can also fit in the plot. In addition, the axes have been rescaled a bit.

  • Merged pull requests (duration before merging)
    • by laurmaedje or reknih β€” Purple dots
    • by other human contributors β€” Blue dots
    • by dependabot β€” not drawn
  • Closed pull requests (duration before closing) β€” Gray dots
  • Open pull requests (duration up to now) β€” Green dots
Changes

fetch_data.py: Change mergedAt to closedAt.

plot_data.py:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "matplotlib",
# ]
# ///
import json
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path

from matplotlib.pyplot import subplots

file = Path("data.jsonl")
now = datetime.now(tz=UTC)


@dataclass
class Record:
    createdAt: datetime
    duration: timedelta


merged_pulls: deque[Record] = deque()
staff_pulls: deque[Record] = deque()
closed_pulls: deque[Record] = deque()
open_pulls: deque[Record] = deque()

staff = ["laurmaedje", "reknih"]
bot = ["dependabot"]

with file.open(encoding="utf-8") as f:
    while line := f.readline():
        row = json.loads(line)

        author: dict | None = row["author"]
        if author is not None and author["login"] in bot:
            # `author` might be `None`
            # See https://github.com/ghost and https://github.com/typst/typst/pull/1047
            continue

        createdAt = datetime.fromisoformat(row["createdAt"])
        closedAt = datetime.fromisoformat(row["closedAt"]) if row["closedAt"] else now
        duration = closedAt - createdAt
        record = Record(createdAt=createdAt, duration=duration)

        match row["state"]:
            case "MERGED":
                if author is not None and author["login"] in staff:
                    staff_pulls.append(record)
                else:
                    merged_pulls.append(record)
            case "CLOSED":
                closed_pulls.append(record)
            case "OPEN":
                open_pulls.append(record)


xticks = [
    # https://typst.app/blog/2023/january-update
    (datetime(2023, 1, 29), None),
    # https://typst.app/blog/2023/beta-oss-launch
    (datetime(2023, 3, 21), "public beta\n(2023-03-21)"),
    # https://typst.app/docs/changelog/0.1.0/
    (datetime(2023, 4, 4), None),
    # https://typst.app/docs/changelog/0.2.0/
    (datetime(2023, 4, 11), None),
    # https://typst.app/docs/changelog/0.3.0/
    (datetime(2023, 4, 26), None),
    # https://typst.app/docs/changelog/0.4.0/
    (datetime(2023, 5, 20), None),
    # https://typst.app/docs/changelog/0.5.0/
    (datetime(2023, 6, 9), None),
    # https://typst.app/docs/changelog/0.6.0/
    (datetime(2023, 6, 30), None),
    # https://typst.app/docs/changelog/0.7.0/
    (datetime(2023, 8, 7), "v0.7.0"),
    # https://typst.app/docs/changelog/0.8.0/
    (datetime(2023, 9, 13), None),
    # https://typst.app/docs/changelog/0.9.0/
    (datetime(2023, 10, 31), None),
    # https://typst.app/docs/changelog/0.10.0/
    (datetime(2023, 12, 4), "v0.10.0\n(2023-12-04)"),
    # https://typst.app/docs/changelog/0.11.0/
    (datetime(2024, 3, 15), None),
    # https://typst.app/docs/changelog/0.11.1/
    (datetime(2024, 5, 17), "v0.11.1"),
    # https://typst.app/blog/2024/typst-0.12
    (datetime(2024, 10, 18), "v0.12.0\n(2024-10-18)"),
    # https://typst.app/blog/2025/typst-0.13
    (datetime(2025, 2, 19), None),
    # https://typst.app/docs/changelog/0.13.1/
    (datetime(2025, 3, 7), "v0.13.1"),
    (
        # `now` will turn into `np.datetime64`, but it does not support explicit representation of timezones.
        now.astimezone(UTC).replace(tzinfo=None),
        f"now\n({now.date().isoformat()})",
    ),
]

duration_min = min(r.duration for r in merged_pulls)
duration_max = max(r.duration for r in merged_pulls)
yticks = [
    (duration_min, f"{duration_min / timedelta(seconds=1):.0f} sec. (min)"),
    (timedelta(days=1), "a day"),
    (timedelta(days=2), "2 days"),
    (timedelta(days=3), "3 days"),
    (timedelta(weeks=1), "a week"),
    (timedelta(weeks=2), "2 weeks"),
    (timedelta(weeks=4), "4 weeks"),
    (timedelta(days=30 * 2), "2 months"),
    (timedelta(days=30 * 4), "4 months"),
    (duration_max, f"{duration_max / timedelta(days=30):.2} mon. (max)"),
    (timedelta(days=30 * 9), "9 months"),
    (timedelta(days=365), "a year"),
]

fig, ax = subplots(layout="constrained")
ax.set_title("Pull requests to typst/typst", fontsize="xx-large", fontweight="black")
ax.set_xlabel("Date of creation", fontsize="large", fontweight="bold")
ax.set_ylabel("Duration", fontsize="large", fontweight="bold")


for pulls, color in [
    (closed_pulls, "gray"),
    (merged_pulls, "#239dad"),
    (staff_pulls, "purple"),
    (open_pulls, "green"),
]:
    ax.scatter(
        [r.createdAt for r in pulls],
        [r.duration.total_seconds() for r in pulls],
        marker="o",
        color=color,
        alpha=0.5,
        s=8,
    )

ax.set_yscale("symlog", linthresh=yticks[3][0].total_seconds())
ax.set(
    xlim=(xticks[0][0], xticks[-1][0] + timedelta(days=30)),
    xticks=[t for t, s in xticks if s],
    xticklabels=[s for _t, s in xticks if s],
    ylim=(
        -timedelta(days=0.2).total_seconds(),
        max(r.duration.total_seconds() for r in merged_pulls) * 3,
    ),
    yticks=[t[0].total_seconds() for t in yticks],
    yticklabels=(t[1] for t in yticks),
)
ax.set_xticks([t for t, s in xticks if not s], minor=True)

ax.grid(axis="x", which="major")
ax.grid(axis="x", which="minor", linewidth=1 / 4)
ax.grid(axis="y", which="major")

fig.savefig(Path(__file__).with_suffix(".png"))

Issues per week

Python scripts

fetch_data.py

from pathlib import Path
from pprint import pprint
from subprocess import run
import json

file = Path("data.jsonl")

query = """
query($cursor: String) {
  repository(owner: "typst", name: "typst") {
    issues(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: ASC}) {
      pageInfo {
        hasNextPage
        endCursor
      }
      nodes {
        number
        title
        createdAt
        closedAt
      }
    }
  }
}
""".strip()


page: dict | None = None
while page is None or page["hasNextPage"]:
    result = run(
        ["gh", "api", "graphql", "--raw-field", f"query={query}"]
        + (["--field", f"cursor={page['endCursor']}"] if page else []),
        text=True,
        check=True,
        capture_output=True,
    )
    data = json.loads(result.stdout)["data"]["repository"]["issues"]
    page = data["pageInfo"]
    pulls = data["nodes"]

    pprint(page)

    for p in pulls:
        pprint(p)
        with file.open("a", encoding="utf-8") as f:
            f.write(json.dumps(p) + "\n")

plot_data.py

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "matplotlib",
# ]
# ///
import json
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Iterable

from matplotlib.pyplot import show, subplots

file = Path("data.jsonl")
now = datetime.now(tz=UTC)


closedAt: deque[datetime] = deque()
createdAt: deque[datetime] = deque()

with file.open(encoding="utf-8") as f:
    while line := f.readline():
        row = json.loads(line)
        at = datetime.fromisoformat(row["createdAt"])
        if at > datetime(2023, 1, 1, tzinfo=UTC):
            createdAt.append(at)

            if (at := row["closedAt"]) is not None:
                closedAt.append(datetime.fromisoformat(at))


def arange(a, b, step):
    x = a
    while x < b:
        yield x
        x += step


xticks = [
    # https://typst.app/blog/2023/january-update
    (datetime(2023, 1, 29), None),
    # https://typst.app/blog/2023/beta-oss-launch
    (datetime(2023, 3, 21), "public beta\n(2023-03-21)"),
    # https://typst.app/docs/changelog/0.1.0/
    (datetime(2023, 4, 4), None),
    # https://typst.app/docs/changelog/0.2.0/
    (datetime(2023, 4, 11), None),
    # https://typst.app/docs/changelog/0.3.0/
    (datetime(2023, 4, 26), None),
    # https://typst.app/docs/changelog/0.4.0/
    (datetime(2023, 5, 20), None),
    # https://typst.app/docs/changelog/0.5.0/
    (datetime(2023, 6, 9), None),
    # https://typst.app/docs/changelog/0.6.0/
    (datetime(2023, 6, 30), None),
    # https://typst.app/docs/changelog/0.7.0/
    (datetime(2023, 8, 7), "v0.7.0"),
    # https://typst.app/docs/changelog/0.8.0/
    (datetime(2023, 9, 13), None),
    # https://typst.app/docs/changelog/0.9.0/
    (datetime(2023, 10, 31), None),
    # https://typst.app/docs/changelog/0.10.0/
    (datetime(2023, 12, 4), "v0.10.0\n(2023-12-04)"),
    # https://typst.app/docs/changelog/0.11.0/
    (datetime(2024, 3, 15), None),
    # https://typst.app/docs/changelog/0.11.1/
    (datetime(2024, 5, 17), "v0.11.1"),
    # https://typst.app/blog/2024/typst-0.12
    (datetime(2024, 10, 18), "v0.12.0\n(2024-10-18)"),
    # https://typst.app/blog/2025/typst-0.13
    (datetime(2025, 2, 19), None),
    # https://typst.app/docs/changelog/0.13.1/
    (datetime(2025, 3, 7), "v0.13.1"),
    (
        # `now` will turn into `np.datetime64`, but it does not support explicit representation of timezones.
        now.astimezone(UTC).replace(tzinfo=None),
        f"now\n({now.date().isoformat()})",
    ),
]

fig, ax = subplots(layout="constrained")
ax.set_title("Issues per week")

bins = list(arange(min(createdAt), max(createdAt), timedelta(days=7)))
ax.hist(
    createdAt,
    label="created",
    color="#21a547",
    bins=bins,
)
ax.hist(
    closedAt,
    label="closed",
    color="#b695f4",
    weights=[-1] * len(closedAt),
    bins=bins,
)

ax.axhline(0, color="black")
ax.legend()
ax.grid()

ax.set(
    xticks=[t for t, s in xticks if s],
    xticklabels=[s for _t, s in xticks if s],
)
ax.set_xticks([t for t, s in xticks if not s], minor=True)
ax.grid(axis="x", which="major", linewidth=1)
ax.grid(axis="x", which="minor", linewidth=1 / 4)

# show()
fig.savefig(Path(__file__).with_suffix(".png"))

Total number of issues

Python scripts

Data: Same as above.

pyproject.toml

[project]
name = "typst-pulls"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "matplotlib>=3.10.5",
    "polars>=1.32.0",
]

[dependency-groups]
dev = [
    "ipykernel>=6.30.0",
]

plot_data.py

from datetime import UTC, datetime, timedelta
from pathlib import Path

import polars as pl
from matplotlib.pyplot import subplots

file = Path("data.jsonl")
df = pl.read_ndjson(file, schema={"createdAt": pl.Datetime, "closedAt": pl.Datetime})


fig, ax = subplots()
ax.set(xlabel="Date", title="Number of issues")

ax.fill_between(
    "createdAt",
    "index",
    data=df.sort(by='createdAt').select([pl.col("createdAt"), pl.row_index()]),
    color="#21a547",
    label="total",
)
ax.fill_between(
    "closedAt",
    "index",
    data=df.drop_nulls().sort(by='closedAt').select([pl.col("closedAt"), pl.row_index()]),
    color="#b695f4",
    label="closed",
)

ax.legend()
ax.grid(True)


now = datetime.now(tz=UTC)

xticks = [
    # https://typst.app/blog/2023/january-update
    (datetime(2023, 1, 29), None),
    # https://typst.app/blog/2023/beta-oss-launch
    (datetime(2023, 3, 21), "public beta\n(2023-03-21)"),
    # https://typst.app/docs/changelog/0.1.0/
    (datetime(2023, 4, 4), None),
    # https://typst.app/docs/changelog/0.2.0/
    (datetime(2023, 4, 11), None),
    # https://typst.app/docs/changelog/0.3.0/
    (datetime(2023, 4, 26), None),
    # https://typst.app/docs/changelog/0.4.0/
    (datetime(2023, 5, 20), None),
    # https://typst.app/docs/changelog/0.5.0/
    (datetime(2023, 6, 9), None),
    # https://typst.app/docs/changelog/0.6.0/
    (datetime(2023, 6, 30), None),
    # https://typst.app/docs/changelog/0.7.0/
    (datetime(2023, 8, 7), "v0.7.0"),
    # https://typst.app/docs/changelog/0.8.0/
    (datetime(2023, 9, 13), None),
    # https://typst.app/docs/changelog/0.9.0/
    (datetime(2023, 10, 31), None),
    # https://typst.app/docs/changelog/0.10.0/
    (datetime(2023, 12, 4), "v0.10.0\n(2023-12-04)"),
    # https://typst.app/docs/changelog/0.11.0/
    (datetime(2024, 3, 15), None),
    # https://typst.app/docs/changelog/0.11.1/
    (datetime(2024, 5, 17), "v0.11.1"),
    # https://typst.app/blog/2024/typst-0.12
    (datetime(2024, 10, 18), "v0.12.0\n(2024-10-18)"),
    # https://typst.app/blog/2025/typst-0.13
    (datetime(2025, 2, 19), None),
    # https://typst.app/docs/changelog/0.13.1/
    (datetime(2025, 3, 7), "v0.13.1"),
    (
        # `now` will turn into `np.datetime64`, but it does not support explicit representation of timezones.
        now.astimezone(UTC).replace(tzinfo=None),
        f"now\n({now.date().isoformat()})",
    ),
]

ax.set(
    xlim=(xticks[0][0], xticks[-1][0] + timedelta(days=30)),
    xticks=[t for t, s in xticks if s],
    xticklabels=[s for _t, s in xticks if s],
)
ax.set_xticks([t for t, s in xticks if not s], minor=True)
ax.grid(axis="x", which="major", linewidth=1)
ax.grid(axis="x", which="minor", linewidth=1 / 4)

fig.savefig(Path(__file__).with_suffix(".png"))

Star History

图片

13 Likes

Background: Expand contributing docs by laurmaedje Β· Pull Request #7746 Β· typst/typst Β· GitHub increases the estimation in CONTRIBUTING.md.

…increased the time after which you should reach out if there’s no maintainer response on your PR, as 1 month is unfortunately more realistic than 1-2 weeks.

Pull request durations (2026-01 update)

  • Merged pull requests (duration before merging)
    • by laurmaedje or reknih β€” Purple dots
    • by other human contributors β€” Blue dots
    • by dependabot β€” not drawn
  • Closed pull requests (duration before closing) β€” Gray dots
  • Open pull requests (duration up to now) β€” Green dots

Means lines only count closed and merged PRs. Otherwise, durations of the recent PRs will be underestimated.

Source code and data: repo-stats.7z.pretend-to-be-pdf.pdf (557.8 KB)

5 Likes