diff --git a/README.md b/README.md index c298e61..c4f84e8 100644 --- a/README.md +++ b/README.md @@ -16,4 +16,12 @@ df.head() #> Dataframe of contributions ``` +Plotting: + +```python +plotter = githubcontribs.Plotter(df) +plotter.plot_total_number_by_author_by_type() +plotter.plot_number_by_month_by_author() +``` + Contributing: Please run `pre-commit install` and `gitmoji -i` on the CLI before starting to work on this repository! diff --git a/docs/quickstart.ipynb b/docs/quickstart.ipynb index 1634c3f..91fc73e 100644 --- a/docs/quickstart.ipynb +++ b/docs/quickstart.ipynb @@ -21,6 +21,18 @@ "df = fetcher.run(\"lamindb\")\n", "df.head()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57b6dd2e", + "metadata": {}, + "outputs": [], + "source": [ + "plotter = githubcontribs.Plotter(df)\n", + "plotter.plot_total_number_by_author_by_type()\n", + "plotter.plot_number_by_month_by_author()" + ] } ], "metadata": { diff --git a/githubcontribs/__init__.py b/githubcontribs/__init__.py index 83cb245..07bf510 100644 --- a/githubcontribs/__init__.py +++ b/githubcontribs/__init__.py @@ -1,5 +1,6 @@ """Simple analytics for GitHub contributions across an organization.""" -__version__ = "0.1.0" # denote a pre-release for 0.1.0 with 0.1rc1 +__version__ = "0.2a1" # denote a pre-release for 0.1.0 with 0.1rc1 from ._fetcher import Fetcher +from ._plotter import Plotter diff --git a/githubcontribs/_plotter.py b/githubcontribs/_plotter.py new file mode 100644 index 0000000..fc5ce5a --- /dev/null +++ b/githubcontribs/_plotter.py @@ -0,0 +1,271 @@ +import matplotlib.pyplot as plt +import pandas as pd +import seaborn as sns + + +def setup_svg_output(): + """Configure matplotlib to output SVG in Jupyter notebooks.""" + try: + from IPython import get_ipython + + ipython = get_ipython() + if ipython is not None: + ipython.run_line_magic("config", "InlineBackend.figure_formats = ['svg']") + except (ImportError, AttributeError): + # Not in IPython/Jupyter environment, or magic not available + pass + + +class Plotter: + """Initialize the Plotter with a DataFrame of contributions obtained by the `Fetcher`. + + Args: + df: DataFrame containing contribution data with columns: author, type, date, repo. + """ + + def __init__(self, df: pd.DataFrame): + self.df = df + setup_svg_output() + sns.set_theme() + + def plot_total_number_by_author_by_type( + self, + top_n: int = 10, + exclude_author: str = "github-actions[bot]", + start_date: str = None, + ): + """Plot total contributions by author, grouped by contribution type. + + Creates a bar chart showing the number of commits, pull requests, and issues + for the top N contributors. + + Args: + top_n: Number of top contributors to display. Defaults to 10. + exclude_author: Author to exclude from the plot. Defaults to "github-actions[bot]". + start_date: Only include contributions on or after this date (format: "YYYY-MM-DD"). + Defaults to None (no filter). + """ + self._plot_contributions( + x="author", + hue="type", + top_n=top_n, + exclude_author=exclude_author, + start_date=start_date, + ) + + def plot_number_by_month_by_author( + self, + top_n: int = 10, + exclude_author: str = "github-actions[bot]", + type_filter: str = "pr", + start_date: str = None, + ): + """Plot contributions over time by author. + + Creates a bar chart showing contributions aggregated by month, with different + colors for each author. Useful for tracking contributor activity over time. + + Args: + top_n: Number of top contributors to display. Defaults to 10. + exclude_author: Author to exclude from the plot. Defaults to "github-actions[bot]". + type_filter: Show only this type of contribution ("commit", "issue", or "pr"). + Defaults to "pr". + start_date: Only include contributions on or after this date (format: "YYYY-MM-DD"). + Defaults to None (no filter). + """ + self._plot_contributions( + x="time", + hue="author", + top_n=top_n, + exclude_author=exclude_author, + type_filter=type_filter, + start_date=start_date, + ) + + def _plot_contributions( + self, + x: str = "author", + hue: str = "type", + top_n: int = 10, + exclude_author: str = "github-actions[bot]", + time_aggregation: str = "month", + type_filter: str = None, + start_date: str = None, + ): + """A configurable plot showing contributions. + + Args: + x: Variable to plot on x-axis. Options: "author", "time". Defaults to "author". + hue: Variable to use for color grouping. Options: "type", "author". Defaults to "type". + top_n: Number of top items to show (authors or time periods). Defaults to 10. + exclude_author: Author to exclude from the plot. Defaults to "github-actions[bot]". + time_aggregation: Time aggregation level when x="time". Options: "day", "week", "month", "year". Defaults to "month". + type_filter: Filter to specific contribution type. Options: "commit", "issue", "pr", or None for all types. + start_date: Filter contributions to only include those on or after this date. Format: "YYYY-MM-DD". Defaults to None (no filter). + """ + df = self.df[self.df.author != exclude_author].copy() + + # Convert date column to datetime + df["date"] = pd.to_datetime(df["date"]) + + # Filter by start_date if specified + if start_date is not None: + start_date_dt = pd.to_datetime(start_date) + df = df[df["date"] >= start_date_dt] + + # Filter by type if specified + if type_filter is not None: + if type_filter not in ["commit", "issue", "pr"]: + raise ValueError( + f"Invalid type_filter: {type_filter}. Must be 'commit', 'issue', 'pr', or None" + ) + df = df[df.type == type_filter] + + # Prepare data based on configuration + if x == "time": + # Aggregate by time period + if time_aggregation == "day": + df["time_period"] = df["date"].dt.to_period("D").astype(str) + elif time_aggregation == "week": + df["time_period"] = df["date"].dt.to_period("W").astype(str) + elif time_aggregation == "month": + df["time_period"] = df["date"].dt.to_period("M").astype(str) + elif time_aggregation == "year": + df["time_period"] = df["date"].dt.to_period("Y").astype(str) + else: + raise ValueError(f"Invalid time_aggregation: {time_aggregation}") + + if hue == "author": + # Group by time and author + plot_data = ( + df.groupby(["time_period", "author"]) + .size() + .reset_index(name="Count") + ) + + # Get top N authors by total contributions + top_authors = df.groupby("author").size().nlargest(top_n).index + plot_data = plot_data[plot_data["author"].isin(top_authors)] + + # Sort time periods + plot_data = plot_data.sort_values("time_period") + + x_var = "time_period" + hue_var = "author" + x_label = f"Time ({time_aggregation})" + hue_label = "Author" + palette = None # Use default palette for many authors + + elif hue == "type": + # Group by time and type + plot_data = ( + df.groupby(["time_period", "type"]).size().reset_index(name="Count") + ) + + # Map type names + type_map = {"commit": "Commits", "issue": "Issues", "pr": "PRs"} + plot_data["type"] = plot_data["type"].map(type_map) + + # Sort time periods and optionally limit to top_n periods + plot_data = plot_data.sort_values("time_period") + time_periods = ( + plot_data.groupby("time_period")["Count"] + .sum() + .nlargest(top_n) + .index + ) + plot_data = plot_data[plot_data["time_period"].isin(time_periods)] + plot_data = plot_data.sort_values("time_period") + + x_var = "time_period" + hue_var = "type" + x_label = f"Time ({time_aggregation})" + hue_label = "Activity Type" + palette = ["#2ecc71", "#3498db", "#e74c3c"] + else: + raise ValueError(f"Invalid hue for x='time': {hue}") + + elif x == "author": + if hue == "type": + # Original behavior: group by author and type + commits_df = df[df.type == "commit"] + issues_df = df[df.type == "issue"] + prs_df = df[df.type == "pr"] + + contributors_data = pd.concat( + [ + prs_df.groupby("author").size().rename("PRs"), + commits_df.groupby("author").size().rename("Commits"), + issues_df.groupby("author").size().rename("Issues"), + ], + axis=1, + ).fillna(0) + + contributors_data["Total"] = contributors_data.sum(axis=1) + contributors_data = contributors_data.sort_values( + "Total", ascending=False + ).head(top_n) + contributors_data = contributors_data.drop("Total", axis=1) + + plot_data = contributors_data.reset_index().melt( + id_vars="author", var_name="Activity Type", value_name="Count" + ) + + x_var = "author" + hue_var = "Activity Type" + x_label = "Author" + hue_label = "Activity Type" + palette = ["#2ecc71", "#3498db", "#e74c3c"] + + elif hue == "time": + raise ValueError("hue='time' is not supported when x='author'") + else: + raise ValueError(f"Invalid hue for x='author': {hue}") + else: + raise ValueError(f"Invalid x value: {x}") + + # Calculate date range + min_date = df["date"].min() + max_date = df["date"].max() + date_range = ( + f"{min_date.strftime('%Y-%m-%d')} to {max_date.strftime('%Y-%m-%d')}" + ) + + # Get repositories + repos = ", ".join(sorted(df["repo"].unique())) + + # Build title with type filter info if applicable + title_parts = [f"Contributions to repositories: {repos}"] + if type_filter is not None: + type_name_map = {"commit": "Commits", "issue": "Issues", "pr": "PRs"} + title_parts[0] = f"{type_name_map[type_filter]} to repositories: {repos}" + title_parts.append(date_range) + title = "\n".join(title_parts) + + # Set up the plot + fig_width = max(12, len(plot_data[x_var].unique()) * 0.8) + plt.figure(figsize=(fig_width, 8)) + + # Create the plot + ax = sns.barplot( + data=plot_data, x=x_var, y="Count", hue=hue_var, palette=palette + ) + + # Add value labels only if not too many bars + if len(plot_data[x_var].unique()) <= 20: + for c in ax.containers: + ax.bar_label(c, label_type="edge", fmt="%d", padding=3) + + # Customize the plot + plt.title(title) + plt.ylabel("Number of contributions") + plt.xlabel(x_label) + + # Rotate x-axis labels for better readability + plt.xticks(rotation=45, ha="right") + + # Position legend + plt.legend(title=hue_label, loc="upper right") + + # Ensure all labels are visible + plt.tight_layout() diff --git a/pyproject.toml b/pyproject.toml index d0148a5..fb0bc69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "pandas", "requests", "dotenv", + "seaborn", ] [project.urls]