Repository: yankeexe/good-first-issues

Issue:
Access latest good first issue
How do I get the latest good first issue for a particular day?

Discussion:

Solution patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 7b95989..c218671 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -19,4 +19,4 @@ jobs:
         run: |
           RUFF_VERSION=$(grep 'ruff==' setup.py | sed -n 's/.*ruff==\([0-9.]*\).*/\1/p')
           pip install ruff=="$RUFF_VERSION"
-          make lint
+          make check
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..baa9df5
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,18 @@
+{
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "gfi:debugger",
+            "type": "debugpy",
+            "request": "launch",
+            "program": "${workspaceFolder}/venv/bin/gfi",
+            "args": ["search","facebook"],
+            "console": "integratedTerminal",
+            "python": "${workspaceFolder}/venv/bin/python",
+            "cwd": "${workspaceFolder}",
+            "env": {
+                "PYTHONPATH": "${workspaceFolder}"
+            }
+        }
+    ]
+}
diff --git a/README.md b/README.md
index d853bf5..d95ba52 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
 
 ## Install Good First Issues
 
-Requires Python 3.6.1 or higher.
+Requires Python 3.9 or higher.
 
 ```bash
 $ pip3 install good-first-issues --upgrade
@@ -127,6 +127,41 @@ $ gfi search --hacktoberfest
 $ gfi search -hf
 ```
 
+### Search for issues within a certain period
+
+By default, no period is set and users are shown whatever data is fetched from the GitHub API.
+
+To filter good first issues within a certain period, use the following commands:
+
+```bash
+
+# Query all organization repos
+$ gfi search "rust-lang" -p "30 hours"
+
+# Query a specific repo in an organization
+$ gfi search "rust-lang" --repo "rust" -p "30 mins"
+
+# Query repos with the topic hacktoberfest
+$ gfi search -hf -p "100 days"
+
+# Query all user repos
+$ gfi search "yankeexe" --user -p "600 hrs"
+
+# Query a specific repo of a user
+$ gfi search "yankeexe" --user --repo "good-first-issues" -p "600 days"
+```
+
+```bash
+# Example Usage:
+--period 1 m,min,mins,minutes
+
+--period 2 h,hr,hour,hours,hrs
+
+--period 3 d,day,days
+
+```
+
+
 > <details><summary><strong>Demo</strong></summary>
 > <img src = "https://i.imgur.com/6Ch5BFG.gif" width="700" alt="demo of timezone cli search" />
 
diff --git a/good_first_issues/commands/search.py b/good_first_issues/commands/search.py
index 0cd5e54..78f23c6 100644
--- a/good_first_issues/commands/search.py
+++ b/good_first_issues/commands/search.py
@@ -8,10 +8,38 @@ from tabulate import tabulate
 
 from good_first_issues import utils
 from good_first_issues.graphql import services
+from good_first_issues.utils import ParsedDuration, parse_period
 
 console = Console(color_system="auto")
 
 
+period_help_msg = """
+Specify a time range for filtering data.
+Converts the specified time range to UTC date time.
+
+# Query all organization repos
+$ gfi search "rust-lang" --period "30 hours"
+
+# Query a specific repo in an organization
+$ gfi search "rust-lang" --repo "rust" -p "30 mins"
+
+# Query repos with the topic hacktoberfest
+$ gfi search -hf -p "100 days"
+
+# Query all user repos
+$ gfi search "yankeexe" --user -p "600 hrs"
+
+# Query a specific repo of a user
+$ gfi search "yankeexe" --user --repo "good-first-issues" -p "600 days"
+
+--period 1 m,min,mins,minutes
+
+--period 2 h,hr,hour,hours,hrs
+
+--period 3 d,day,days
+"""
+
+
 @click.command()
 @click.option(
     "--repo",
@@ -30,6 +58,7 @@ console = Console(color_system="auto")
     "-l",
     help="Limit the number of issues to display. Defaults to 10",
     type=int,
+    default=10,
 )
 @click.option(
     "--user",
@@ -48,6 +77,7 @@ console = Console(color_system="auto")
     help="View all the issues found without limits.",
     is_flag=True,
 )
+@click.option("--period", "-p", help=period_help_msg)
 @click.argument("name", required=False)
 def search(
     name: str,
@@ -57,6 +87,7 @@ def search(
     limit: int,
     all: bool,
     hacktoberfest: bool,
+    period: str,
 ):
     """Search for good first issues in organizations or user repositories.
 
@@ -66,7 +97,7 @@ def search(
 
     ➡️ repo owner
 
-        gfi search "yankeexe"
+        gfi search "yankeexe" --user
 
     ➡️ org name
 
@@ -90,8 +121,14 @@ def search(
     # Check for GitHub Token.
     token: Union[str, bool] = utils.check_credential()
 
+    if period:
+        period: ParsedDuration = parse_period(period)
+        period = period.utc_date_time.strftime("%Y-%m-%dT%H:%M:%SZ")
+
     # Identify the flags passed.
-    query, variables, mode = services.identify_mode(name, repo, user, hacktoberfest)
+    query, variables, mode = services.identify_mode(
+        name, repo, user, hacktoberfest, period, limit
+    )
 
     # Spinner
     spinner = Halo(text="Fetching repos...", spinner="dots")
@@ -107,10 +144,11 @@ def search(
         issues, rate_limit = services.org_user_pipeline(response, mode)
 
     if mode == "repo":
-        issues, rate_limit = services.extract_repo_issues(response)
+        issues, rate_limit = services.org_user_pipeline(response, mode)
 
     if mode == "search":
         issues, rate_limit = services.extract_search_results(response)
+        issues = issues[:limit]  # cannot set limit on the search_query directly
 
     table_headers: List = ["Title", "Issue URL"]
 
@@ -125,19 +163,16 @@ def search(
             "No good first issues found!:mask:",
             style="bold red",
         )
-    # Handle limiting the output displayed.
-    limiter = utils.identify_limit(limit, all)
 
     # Handle displaying issues on browser.
     if web:
-        html_data = tabulate(issues[:limiter], table_headers, tablefmt="html")
+        html_data = tabulate(issues, table_headers, tablefmt="html")
         return utils.web_server(html_data)
 
-    issue_count: int = len(issues)
-    row_ids: List[int] = utils.get_row_ids(issue_count, limiter)
+    row_ids = list(range(1, len(issues) + 1))
     print(
         tabulate(
-            issues[:limiter],
+            issues,
             table_headers,
             tablefmt="fancy_grid",
             showindex=row_ids,
diff --git a/good_first_issues/graphql/queries.py b/good_first_issues/graphql/queries.py
index de89490..ab9b15b 100644
--- a/good_first_issues/graphql/queries.py
+++ b/good_first_issues/graphql/queries.py
@@ -1,74 +1,43 @@
 """GraphQL queries"""
 
-org_query: str = """
-query getIssues($name: String!) {
-  organization(login: $name) {
-    repositories(first: 100) {
-      edges {
-        node {
-          name
-          issues(first: 100, states: OPEN, labels: "good first issue") {
-            edges {
-              node {
-                title
-                url
-              }
-            }
-          }
-        }
-      }
-    }
-  }
+core_query = """
+query SearchGoodFirstIssues($searchQuery: String!, $limit: Int!) {
   rateLimit {
+    limit
+    cost
     remaining
+    resetAt
   }
-}
-"""
-
-user_query: str = """
-query getIssues ($name: String!) {
-  user(login: $name) {
-    repositories(first: 100, ownerAffiliations: OWNER) {
-      edges {
-        node {
+  search(query: $searchQuery, type: ISSUE, first: $limit) {
+    issueCount
+    nodes {
+      ... on Issue {
+        title
+        url
+        createdAt
+        author {
+          login
+        }
+        repository {
           name
-          issues(filterBy: {labels: "good first issue", states: OPEN}, first: 100) {
-            edges {
-              node {
-                title
-                url
-              }
-            }
+          owner {
+            login
           }
         }
-      }
-    }
-  }
-  rateLimit {
-    remaining
-  }
-}
-
-"""
-
-repo_query: str = """
-query getIssues($name: String!, $owner: String!) {
-  repository(name: $name, owner: $owner) {
-    issues(filterBy: { states: OPEN, labels: "good first issue" }, first: 100) {
-      edges {
-        node {
-          title
-          url
+        labels(first: 3) {
+          nodes {
+            name
+          }
         }
+        number
+        state
       }
     }
   }
-  rateLimit {
-    remaining
-  }
 }
 """
 
+# `first: 2` = Fetch only 2 issues from each repos with the topic hacktoberfest
 search_query: str = """
 query search($queryString: String!){
   search(type: REPOSITORY, query: $queryString, first: 50) {
@@ -76,7 +45,7 @@ query search($queryString: String!){
       node {
         ... on Repository {
           url
-          issues(filterBy: { states: OPEN, labels: "good first issue" }, first: 100) {
+          issues(filterBy: { states: OPEN, labels: "good first issue" }, first: 2) {
             edges {
             node {
                   title
diff --git a/good_first_issues/graphql/services.py b/good_first_issues/graphql/services.py
index 86d810d..1e53f5c 100644
--- a/good_first_issues/graphql/services.py
+++ b/good_first_issues/graphql/services.py
@@ -10,12 +10,7 @@ from requests.models import Response
 from rich.console import Console
 from urllib3.util.retry import Retry
 
-from good_first_issues.graphql.queries import (
-    org_query,
-    repo_query,
-    search_query,
-    user_query,
-)
+from good_first_issues.graphql.queries import core_query, search_query
 
 # Initializations
 console = Console(color_system="auto")
@@ -35,11 +30,7 @@ def org_user_pipeline(payload: Dict, mode: str) -> Tuple[Iterable, int]:
     """
     Extract issues related to organization or a user.
     """
-    # Identify owner_type for selecting proper GraphQL query.
-    owner_type: str = "organization" if mode == "org" else "user"
-
-    # Get the edges connecting to all the repositories.
-    base_data: List = payload["data"].get(owner_type).get("repositories").get("edges")
+    base_data: List = payload.get("data").get("search").get("nodes")
 
     # Extract rate limit value.
     rate_limit: int = payload["data"].get("rateLimit").get("remaining")
@@ -47,11 +38,12 @@ def org_user_pipeline(payload: Dict, mode: str) -> Tuple[Iterable, int]:
     spinner.start()
 
     # Generator pipeline: Extract issue title and url.
-    pipeline: Iterable = get_issues(get_base_issues(base_data))
+    # pipeline: Iterable = get_issues(get_base_issues(base_data))
+    issues = [(data.get("title"), data.get("url")) for data in base_data]
 
     spinner.succeed("Search Complete.")
 
-    return list(pipeline), rate_limit
+    return issues, rate_limit
 
 
 def get_base_issues(data: List) -> BaseIssueEdges:
@@ -125,7 +117,7 @@ def extract_search_results(payload: Dict) -> Tuple[Iterable, int]:
 
 
 def identify_mode(
-    name: str, repo: str, user: bool, hacktoberfest: bool
+    name: str, repo: str, user: bool, hacktoberfest: bool, period: str, limit: int
 ) -> Tuple[str, Dict, str]:
     """
     Identify the mode based on arguments passed.
@@ -135,41 +127,61 @@ def identify_mode(
     2. variables for the query
     3. function(mode) to pass the above values to
     """
-    variables: Dict = dict()
+    variables: Dict = {"limit": limit}
 
-    if name and repo and user:
-        query = repo_query
-        variables["owner"] = name
-        variables["name"] = repo
+    base_variable = 'label:"good first issue" is:open is:issue'
+
+    if period:
+        base_variable = f"{base_variable} created:>={period}"
+
+    if name and user and repo:
+        # If CLI gets the --user flag along with the --repo flag, look into that particular repo.
+        query = core_query
+        variables["searchQuery"] = f"repo:{name}/{repo} {base_variable}"
         mode: str = "repo"
+
     elif name and repo:
-        query = repo_query
-        variables["owner"] = name
-        variables["name"] = repo
+        # If CLI gets the --repo flag, looking into that particular repo.
+        query = core_query
+        variables["searchQuery"] = f"repo:{name}/{repo} {base_variable}"
         mode = "repo"
+
     elif name and user:
-        query = user_query
-        variables["name"] = name
+        # If CLI gets --user flag, looks into user repos.
+        query = core_query
+        variables["searchQuery"] = f"user:{name} {base_variable}"
         mode = "user"
-    else:
-        query = org_query
-        variables["name"] = name
-        mode = "org"
 
-    if hacktoberfest and not repo:
+    elif name and hacktoberfest and not repo:
         query = search_query
-        variables["queryString"] = "topic:hacktoberfest "
-        if name:
-            variables["queryString"] += f"user:{name}"
+        search_query_var = "topic:hacktoberfest"
+        if period:
+            search_query_var = f"{search_query_var} created:>={period} org:{name}"
+        variables["queryString"] = search_query_var
         mode = "search"
 
-    if repo and hacktoberfest:
+    elif hacktoberfest and not repo:
+        query = search_query
+        search_query_var = "topic:hacktoberfest"
+        if period:
+            search_query_var = f"{search_query_var} created:>={period}"
+
+        variables["queryString"] = search_query_var
+        del variables["limit"]
+
+    elif repo and hacktoberfest:
         console.print(
             "Error: --hacktoberfest or --hf cannot be used with --repo flag:x:",
             style="bold red",
         )
         sys.exit()
 
+    else:
+        # if CLI gets not flag, defaults to looking into org repos.
+        query = core_query
+        variables["searchQuery"] = f"org:{name} {base_variable}"
+        mode = "org"
+
     return query, variables, mode
 
 
diff --git a/good_first_issues/utils/__init__.py b/good_first_issues/utils/__init__.py
index a804750..8d41be5 100644
--- a/good_first_issues/utils/__init__.py
+++ b/good_first_issues/utils/__init__.py
@@ -1,13 +1,16 @@
 """Utils for CLI"""
 
+import datetime
 import http.server
 import os
 import re
 import shutil
 import socketserver
+import sys
 import webbrowser
+from collections import namedtuple
 from pathlib import Path
-from typing import Dict, List, Optional, Union
+from typing import Dict, Union
 
 import click
 from halo import Halo
@@ -26,6 +29,77 @@ credential_dir: str = f"{home_dir}/.gfi"
 credential_file: str = f"{credential_dir}/{filename}"
 
 
+ParsedDuration = namedtuple("ParsedDuration", ["absolute_period", "utc_date_time"])
+
+
+def parse_period(duration: str) -> ParsedDuration:
+    """Parses a duration string into absolute period and UTC datetime.
+
+    Args:
+        duration: String representing a duration in minutes, hours or days.
+            Format: <number><unit> where unit can be:
+            - m, min, mins, minutes for minutes
+            - h, hr, hrs, hours for hours
+            - d, day, days for days
+
+    Returns:
+        ParsedDuration: A named tuple containing:
+            - absolute_period: Duration in minutes
+            - utc_date_time: UTC datetime representing duration from now
+
+    Raises:
+        SystemExit: If duration string format is invalid
+    """
+    pattern = r"^(?P<Period>\d+)\s?(?P<Duration>m|min|mins|minutes|h|hr|hrs|hours|d|day|days)?$"
+    regex = re.compile(pattern)
+    match = regex.match(duration)
+
+    if not match:
+        print(
+            "❌ Invalid status duration\nUse 'gfi search --help for  more information.",
+            file=sys.stderr,
+        )
+        sys.exit(1)
+
+    period_str = match.group("Period")
+    duration_type = match.group("Duration")
+
+    # Convert period to integer and ensure it's non-negative
+    period = int(period_str)
+    period = abs(period)
+
+    if not duration_type:
+        print(
+            "Invalid duration.\nUse 'gfi search --help for  more information.",
+            file=sys.stderr,
+        )
+        sys.exit(1)
+
+    if duration_type in ["m", "min", "mins", "minutes"]:
+        abs_period = period
+        utc_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
+            minutes=period
+        )
+    elif duration_type in ["h", "hr", "hrs", "hours"]:
+        abs_period = period * 60
+        utc_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
+            hours=period
+        )
+    elif duration_type in ["d", "day", "days"]:
+        abs_period = period * 60 * 24
+        utc_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
+            days=period
+        )
+    else:
+        print("Invalid duration type", file=sys.stderr)
+        sys.exit(1)
+
+    return ParsedDuration(
+        absolute_period=abs_period,
+        utc_date_time=utc_time,
+    )
+
+
 def print_help_msg(command):
     """
     Prints help message for passed command.
@@ -85,30 +159,6 @@ def gql_rate_limit() -> int:
     return payload["data"].get("rateLimit").get("remaining")
 
 
-def identify_limit(limit: Optional[int], all: bool) -> Optional[int]:
-    """
-    Define the value of limiter based on the values passed.
-    """
-    if limit:
-        return limit
-    elif all:
-        return None
-    else:
-        return 10
-
-
-def get_row_ids(issue_count: int, limiter: Optional[int]) -> List[int]:
-    """
-    Generate custom Row Index Column for the table.
-    Returns a Iterable.
-    """
-
-    # Check limit is None or greater than total issues fetched
-    if limiter is None or issue_count < limiter:
-        return [i for i in range(1, issue_count + 1)]
-    return [i for i in range(1, limiter + 1)]
-
-
 def web_server(html_data):
     """
     Start web server for obtained issues.
diff --git a/setup.py b/setup.py
index 415a6e9..5b3a386 100644
--- a/setup.py
+++ b/setup.py
@@ -12,13 +12,12 @@ requirements = [
     "tabulate==0.9.0",
     "rich<=13.7.1",
 ]
-requirements_dev = ["pytest==8.2.2", "ruff==0.6.4", "pre-commit", "mypy"]
+requirements_dev = ["pytest==8.2.2", "ruff==0.9.4", "pre-commit", "mypy"]
 
 setuptools.setup(
     name="good-first-issues",
-    version="2.1.2",
+    version="2.1.3",
     author="Yankee Maharjan",
-    author_email="yankee.exe@gmail.com",
     url="https://github.com/yankeexe/good-first-issues",
     description="Find good first issues right from your CLI!",
     license="MIT",
@@ -28,12 +27,9 @@ setuptools.setup(
     install_requires=requirements,
     extras_require={"dev": requirements_dev},
     entry_points={"console_scripts": ["gfi = good_first_issues.main:cli"]},
-    python_requires=">=3.6",
+    python_requires=">=3.9",
     classifiers=[
         "Intended Audience :: Developers",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index bc4de2b..e10512f 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,25 +1,94 @@
+import datetime
+
 import pytest
 
-from good_first_issues.utils import get_row_ids, identify_limit
+from good_first_issues.utils import parse_period
+
+
+# Fixture to mock the current time for consistent testing
+@pytest.fixture
+def mock_datetime_now(monkeypatch):
+    # Define a fixed datetime for testing
+    fixed_datetime = datetime.datetime(
+        2023, 10, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
+    )
+
+    # Mock the `datetime.datetime.now` method
+    class MockDateTime(datetime.datetime):
+        @classmethod
+        def now(cls, tz=None):
+            return fixed_datetime
+
+    # Replace the real `datetime.datetime` with our mock
+    monkeypatch.setattr(datetime, "datetime", MockDateTime)
 
 
 @pytest.mark.parametrize(
-    "limit, all, expected",
-    [(50, False, 50), (None, False, 10), (None, True, None), (50, True, 50)],
+    "duration, expected_period, expected_timedelta",
+    [
+        ("10m", 10, datetime.timedelta(minutes=10)),
+        ("5min", 5, datetime.timedelta(minutes=5)),
+        ("15mins", 15, datetime.timedelta(minutes=15)),
+        ("30minutes", 30, datetime.timedelta(minutes=30)),
+        ("2h", 120, datetime.timedelta(hours=2)),
+        ("3hr", 180, datetime.timedelta(hours=3)),
+        ("4hrs", 240, datetime.timedelta(hours=4)),
+        ("5hours", 300, datetime.timedelta(hours=5)),
+        ("1d", 1440, datetime.timedelta(days=1)),
+        ("2day", 2880, datetime.timedelta(days=2)),
+        ("3days", 4320, datetime.timedelta(days=3)),
+    ],
 )
-def test_identify_limit(limit, all, expected):
-    """
-    Check correctness of `limiter` value returned.
-    """
-    assert identify_limit(limit, all) == expected
+def test_parse_period_valid(
+    mock_datetime_now, duration, expected_period, expected_timedelta
+):
+    result = parse_period(duration)
+    assert result.absolute_period == expected_period
+    assert (
+        result.utc_date_time
+        == datetime.datetime(2023, 10, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
+        - expected_timedelta
+    )
 
 
 @pytest.mark.parametrize(
-    "issues, limiter, expected",
-    [(10, None, list(range(1, 11))), (100, 10, list(range(1, 11)))],
+    "duration",
+    [
+        "10",  # Missing duration unit
+        "10x",  # Invalid duration unit
+        "minutes",  # Missing period
+        "-10m",  # Negative period
+        "10.5m",  # Non-integer period
+    ],
 )
-def test_get_row_ids(issues, limiter, expected):
-    """
-    Check correctness of iterable returned.
-    """
-    assert get_row_ids(issues, limiter) == expected
+def test_parse_period_invalid(duration):
+    with pytest.raises(SystemExit):
+        parse_period(duration)
+
+
+def test_parse_period_zero_duration(mock_datetime_now):
+    result = parse_period("0m")
+    assert result.absolute_period == 0
+    assert result.utc_date_time == datetime.datetime(
+        2023, 10, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
+    )
+
+
+def test_parse_period_large_duration(mock_datetime_now):
+    result = parse_period("1000d")
+    assert result.absolute_period == 1000 * 60 * 24
+    assert result.utc_date_time == datetime.datetime(
+        2023, 10, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
+    ) - datetime.timedelta(days=1000)
+
+
+def test_parse_period_error_message(capsys):
+    with pytest.raises(SystemExit):
+        parse_period("10x")
+    captured = capsys.readouterr()
+    assert "❌ Invalid status duration" in captured.err
+
+    with pytest.raises(SystemExit):
+        parse_period("10")
+    captured = capsys.readouterr()
+    assert "Invalid duration" in captured.err