Skip to content

gitea.watch

watch

Change detection over issue snapshots, for watching repositories and boards.

A watch run fetches the issues of the scopes it was asked about, reduces each one to a snapshot of the few fields worth reacting to, and compares those snapshots against the ones a previous run left in a local cache. What comes back is the list of changes since that run, which is empty when nothing moved - the property that lets the command be run from cron without producing output, and so without producing work, on a quiet tick.

gitea.watch.changes holds the comparison and the snapshot it compares; gitea.watch.state holds the cache the snapshots are kept in.

Functions:

gitea.watch.comment_hash

comment_hash(comment: dict[str, Any]) -> str

Hash a comment's identity, stably across re-fetches.

The digest is taken over the comment's ID, author, body and timestamps, serialized as JSON so that no value can be confused with the boundary between two of them - a body ending in the separator would otherwise hash as a different comment's body beginning with it.

Including the timestamps means an edited comment hashes differently from the comment it replaced, so an edit is reported as a change rather than passing for the comment already recorded.

The author is taken by ID and never by login, because a login is renameable and the digest has to survive a rename: hashing the login would turn every comment a renamed user ever wrote into a removal and an addition, on every issue being watched, although nothing about any of them changed. A comment's author cannot change, so the ID is only there to tell two comments apart when the payload carries no comment ID of its own - and a payload carrying no user ID contributes no author at all rather than falling back to the login, which would put the rename back.

Parameters:

Name Type Description Default
comment dict[str, Any]

The comment data returned by the API. A payload missing any of these fields hashes as if it carried them empty, rather than raising.

required

Returns:

Type Description
str

The first _HASH_LENGTH hex characters of the SHA-256 digest.

Source code in src/gitea/watch/changes.py
def comment_hash(comment: dict[str, Any]) -> str:
    """Hash a comment's identity, stably across re-fetches.

    The digest is taken over the comment's ID, author, body and timestamps,
    serialized as JSON so that no value can be confused with the boundary
    between two of them - a body ending in the separator would otherwise hash as
    a different comment's body beginning with it.

    Including the timestamps means an edited comment hashes differently from the
    comment it replaced, so an edit is reported as a change rather than passing
    for the comment already recorded.

    The author is taken by ID and never by login, because a login is renameable
    and the digest has to survive a rename: hashing the login would turn every
    comment a renamed user ever wrote into a removal and an addition, on every
    issue being watched, although nothing about any of them changed. A comment's
    author cannot change, so the ID is only there to tell two comments apart
    when the payload carries no comment ID of its own - and a payload carrying
    no user ID contributes no author at all rather than falling back to the
    login, which would put the rename back.

    Args:
        comment: The comment data returned by the API. A payload missing any of
            these fields hashes as if it carried them empty, rather than
            raising.

    Returns:
        The first `_HASH_LENGTH` hex characters of the SHA-256 digest.

    """
    user = comment.get("user")
    author = usable_identifier(user.get("id")) if isinstance(user, dict) else None
    identity = [
        comment.get("id"),
        author,
        comment.get("body") if isinstance(comment.get("body"), str) else "",
        comment.get("created_at") if isinstance(comment.get("created_at"), str) else "",
        comment.get("updated_at") if isinstance(comment.get("updated_at"), str) else "",
    ]
    raw = json.dumps(identity, separators=(",", ":"), sort_keys=True, default=str)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:_HASH_LENGTH]

gitea.watch.detect_changes

detect_changes(
    current: dict[str, dict[str, Any]],
    previous: dict[str, dict[str, Any]] | None,
) -> list[dict[str, Any]]

Compare the snapshots of one scope against the ones recorded for it.

An issue that changed in more than one way contributes one record per way, as a digest reads better naming each change than one line naming three.

An issue the recorded scope has not seen is one of those: it contributes the new record naming it, and then the same records everything else contributes, taken against the empty snapshot it is being compared to - so the assignees, labels and comments it already carries are reported as added. Reporting only new would make them baseline, which loses a comment written between an issue being opened and the run that first saw it, and loses it permanently: the run that swallowed it records it and the next one has nothing left to compare against. It matters most to a consumer that reacts to a kind rather than to an issue - one acting on comments and not on new would never learn the comment was there.

Parameters:

Name Type Description Default
current dict[str, dict[str, Any]]

The snapshot of each issue in the scope now, keyed by issue_key.

required
previous dict[str, dict[str, Any]] | None

The snapshots the last run recorded for the scope, or None when the scope has never been recorded. None baselines the scope: no change is reported, whatever is in it.

required

Returns:

Type Description
list[dict[str, Any]]

The changes since the recorded snapshots, ordered by issue and then by

list[dict[str, Any]]

what changed. Empty when nothing changed, which is the whole point of

list[dict[str, Any]]

the command.

Source code in src/gitea/watch/changes.py
def detect_changes(
    current: dict[str, dict[str, Any]],
    previous: dict[str, dict[str, Any]] | None,
) -> list[dict[str, Any]]:
    """Compare the snapshots of one scope against the ones recorded for it.

    An issue that changed in more than one way contributes one record per way,
    as a digest reads better naming each change than one line naming three.

    An issue the recorded scope has not seen is one of those: it contributes the
    `new` record naming it, and then the same records everything else
    contributes, taken against the empty snapshot it is being compared to - so
    the assignees, labels and comments it already carries are reported as added.
    Reporting only `new` would make them baseline, which loses a comment written
    between an issue being opened and the run that first saw it, and loses it
    permanently: the run that swallowed it records it and the next one has
    nothing left to compare against. It matters most to a consumer that reacts
    to a kind rather than to an issue - one acting on `comments` and not on
    `new` would never learn the comment was there.

    Args:
        current: The snapshot of each issue in the scope now, keyed by
            `issue_key`.
        previous: The snapshots the last run recorded for the scope, or None
            when the scope has never been recorded. None baselines the scope:
            no change is reported, whatever is in it.

    Returns:
        The changes since the recorded snapshots, ordered by issue and then by
        what changed. Empty when nothing changed, which is the whole point of
        the command.

    """
    if previous is None:
        return []

    changes: list[dict[str, Any]] = []

    for key, snapshot in sorted(current.items(), key=_sort_key):
        before = previous.get(key)
        if before is None:
            changes.append(_change(snapshot, "new", "new issue", [], []))
            # An issue first seen here is compared against an empty snapshot
            # rather than skipped, so what it already carries is reported as
            # added. Skipping it would make the comments an issue was opened
            # with - or the ones written before the run that first saw it -
            # baseline instead of a change, and they would never be reported:
            # the one record that mentioned the issue would be `new`, which a
            # consumer filtering on the kinds it acts on may not act on at all.
            before = {}

        for field, kind in _FIELD_KINDS:
            added, removed = _delta(before.get(field, []), snapshot.get(field, []))
            if added or removed:
                changes.append(_change(snapshot, kind, _describe_names(added, removed), added, removed))

        added, removed = _delta(before.get("comment_hashes", []), snapshot.get("comment_hashes", []))
        if added or removed:
            changes.append(_change(snapshot, "comments", _describe_comments(added, removed), added, removed))

    gone = {key: snapshot for key, snapshot in previous.items() if key not in current}
    for _, snapshot in sorted(gone.items(), key=_sort_key):
        changes.append(_change(snapshot, "gone", "no longer listed", [], []))

    return changes

gitea.watch.format_change

format_change(change: dict[str, Any]) -> str

Render one change as the line the human digest prints for it.

The issue is named the way it is written in a browser's address bar and in prose - owner/repo#15 - so a line can be read, pasted and grepped without consulting the scope it came from.

Parameters:

Name Type Description Default
change dict[str, Any]

The change record.

required

Returns:

Type Description
str

One line naming the issue, what changed and what it changed to.

Source code in src/gitea/watch/changes.py
def format_change(change: dict[str, Any]) -> str:
    """Render one change as the line the human digest prints for it.

    The issue is named the way it is written in a browser's address bar and in
    prose - `owner/repo#15` - so a line can be read, pasted and grepped without
    consulting the scope it came from.

    Args:
        change: The change record.

    Returns:
        One line naming the issue, what changed and what it changed to.

    """
    number, repository = change.get("number"), change.get("repository")
    if isinstance(number, int):
        reference = f"{repository}#{number}" if repository else f"#{number}"
    else:
        reference = f"issue {change.get('issue_id')}"

    line = f"{reference} {change.get('kind')}: {change.get('detail')}"
    title = change.get("title")
    return f"{line} · {title}" if title else line

gitea.watch.issue_key

issue_key(issue: dict[str, Any]) -> str | None

Build the key an issue is recorded under.

The global ID is used rather than the number shown in the web UI, because a project scope holds issues from several repositories and their numbers collide.

Parameters:

Name Type Description Default
issue dict[str, Any]

The issue data returned by the API.

required

Returns:

Type Description
str | None

The key, or None when the payload carries no usable global ID.

Source code in src/gitea/watch/changes.py
def issue_key(issue: dict[str, Any]) -> str | None:
    """Build the key an issue is recorded under.

    The global ID is used rather than the number shown in the web UI, because a
    project scope holds issues from several repositories and their numbers
    collide.

    Args:
        issue: The issue data returned by the API.

    Returns:
        The key, or None when the payload carries no usable global ID.

    """
    identifier = usable_identifier(issue.get("id"))
    return None if identifier is None else str(identifier)

gitea.watch.issue_snapshot

issue_snapshot(
    issue: dict[str, Any],
    comments: list[dict[str, Any]],
    repository: str | None = None,
) -> dict[str, Any]

Reduce an issue and its comments to what a watch compares and reports.

Parameters:

Name Type Description Default
issue dict[str, Any]

The issue data returned by the API.

required
comments list[dict[str, Any]]

The issue's comments, as returned by the API. Pass an empty list for an issue whose comments were not fetched; its comment hashes are then empty and no comment change is ever reported for it.

required
repository str | None

Full name of the repository holding the issue, used to name it in the report, or None when it could not be determined.

None

Returns:

Type Description
dict[str, Any]

The snapshot of the issue.

Source code in src/gitea/watch/changes.py
def issue_snapshot(
    issue: dict[str, Any], comments: list[dict[str, Any]], repository: str | None = None
) -> dict[str, Any]:
    """Reduce an issue and its comments to what a watch compares and reports.

    Args:
        issue: The issue data returned by the API.
        comments: The issue's comments, as returned by the API. Pass an empty
            list for an issue whose comments were not fetched; its comment
            hashes are then empty and no comment change is ever reported for it.
        repository: Full name of the repository holding the issue, used to name
            it in the report, or None when it could not be determined.

    Returns:
        The snapshot of the issue.

    """
    title = issue.get("title")
    updated_at = issue.get("updated_at")

    return {
        "issue_id": usable_identifier(issue.get("id")),
        "number": usable_identifier(issue.get("number")),
        "title": title if isinstance(title, str) else "",
        "repository": repository,
        "updated_at": updated_at if isinstance(updated_at, str) else "",
        "assignees": _names(issue.get("assignees"), "login"),
        "labels": _names(issue.get("labels"), "name"),
        "comment_hashes": sorted({comment_hash(comment) for comment in comments if isinstance(comment, dict)}),
    }

gitea.watch.usable_identifier

usable_identifier(value: Any) -> int | None

Read a value that has to be a whole number to identify anything.

Every identifier a watch reads - an issue's global ID, its number, a column's ID - is read through here, so that a payload with a nonsense one in it is refused the same way whichever field it was in.

Parameters:

Name Type Description Default
value Any

The value the payload carries for the identifier.

required

Returns:

Type Description
int | None

The identifier, or None when the value is not one. True is not one:

int | None

it is an int as far as Python is concerned, and would key every issue

int | None

whose ID came back as a boolean under the same entry.

Source code in src/gitea/watch/changes.py
def usable_identifier(value: Any) -> int | None:
    """Read a value that has to be a whole number to identify anything.

    Every identifier a watch reads - an issue's global ID, its number, a
    column's ID - is read through here, so that a payload with a nonsense one in
    it is refused the same way whichever field it was in.

    Args:
        value: The value the payload carries for the identifier.

    Returns:
        The identifier, or None when the value is not one. `True` is not one:
        it is an `int` as far as Python is concerned, and would key every issue
        whose ID came back as a boolean under the same entry.

    """
    return value if isinstance(value, int) and not isinstance(value, bool) else None

gitea.watch.cache_lock

cache_lock(path: str | Path) -> Iterator[bool]

Hold the lock guarding one cache for the duration of the block.

The lock is advisory and taken through the operating system, so a run that is killed while holding it releases it rather than leaving a file behind that every later run waits on. The lock file itself is never removed, for the same reason a lock is not the file's existence: unlinking it while another run holds it open would let the next run lock a different file.

Failing to lock is not failing to run. A platform with neither locking call, a filesystem that refuses the lock, and a wait that runs out are all logged and yield anyway, leaving the caller in the read/modify/write race it was in before there was a lock rather than leaving it unable to watch anything.

Parameters:

Name Type Description Default
path str | Path

Path of the cache to guard.

required

Yields:

Type Description
bool

Whether the lock is actually held for the duration of the block.

Source code in src/gitea/watch/state.py
@contextmanager
def cache_lock(path: str | Path) -> Iterator[bool]:
    """Hold the lock guarding one cache for the duration of the block.

    The lock is advisory and taken through the operating system, so a run that
    is killed while holding it releases it rather than leaving a file behind
    that every later run waits on. The lock file itself is never removed, for
    the same reason a lock is not the file's existence: unlinking it while
    another run holds it open would let the next run lock a different file.

    Failing to lock is not failing to run. A platform with neither locking call,
    a filesystem that refuses the lock, and a wait that runs out are all logged
    and yield anyway, leaving the caller in the read/modify/write race it was in
    before there was a lock rather than leaving it unable to watch anything.

    Args:
        path: Path of the cache to guard.

    Yields:
        Whether the lock is actually held for the duration of the block.

    """
    lock = lock_path_for(path)
    try:
        lock.parent.mkdir(parents=True, exist_ok=True)
        handle = os.open(lock, os.O_RDWR | os.O_CREAT, 0o600)
    except OSError as error:
        logger.warning(
            "Could not open the watch cache lock at %s (%s); this run may overwrite what a concurrent one records.",
            lock,
            error,
        )
        yield False
        return

    held = False
    try:
        try:
            held = _take_lock(handle)
            if not held:
                logger.warning(
                    "This build of Python offers no way to lock the watch cache at %s; this run may overwrite what "
                    "a concurrent one records.",
                    lock,
                )
        except OSError as error:
            logger.warning(
                "Could not lock the watch cache at %s (%s); this run may overwrite what a concurrent one records.",
                lock,
                error,
            )
        yield held
    finally:
        if held:
            with contextlib.suppress(OSError):
                _drop_lock(handle)
        os.close(handle)

gitea.watch.default_state_path

default_state_path() -> Path

Build the path of the cache used when none is named.

Returns:

Type Description
Path

The cache file in the user's cache directory for this application.

Source code in src/gitea/watch/state.py
def default_state_path() -> Path:
    """Build the path of the cache used when none is named.

    Returns:
        The cache file in the user's cache directory for this application.

    """
    return Path(platformdirs.user_cache_dir(appname="gitea")) / "watch-state.json"

gitea.watch.load_state

load_state(path: str | Path) -> dict[str, Any]

Read the cache, treating anything unreadable as an absent one.

A missing file is the ordinary first run and is not reported. A file that exists but cannot be read as a cache document is reported as a warning, because it means the scopes in it are about to be baselined again and the changes since the last good write will never be reported.

A file whose bytes are not UTF-8 at all is one of those, and is caught here rather than left to the caller: decoding raises UnicodeDecodeError, which is a ValueError and not an OSError, so catching only the latter would let a cache truncated mid-character - or a wholly unrelated binary file named as one - end the run instead of re-baselining it.

Parameters:

Name Type Description Default
path str | Path

Path of the cache.

required

Returns:

Type Description
dict[str, Any]

The cache document, or an empty one when there is nothing to read.

Source code in src/gitea/watch/state.py
def load_state(path: str | Path) -> dict[str, Any]:
    """Read the cache, treating anything unreadable as an absent one.

    A missing file is the ordinary first run and is not reported. A file that
    exists but cannot be read as a cache document is reported as a warning,
    because it means the scopes in it are about to be baselined again and the
    changes since the last good write will never be reported.

    A file whose bytes are not UTF-8 at all is one of those, and is caught here
    rather than left to the caller: decoding raises `UnicodeDecodeError`, which
    is a `ValueError` and not an `OSError`, so catching only the latter would
    let a cache truncated mid-character - or a wholly unrelated binary file
    named as one - end the run instead of re-baselining it.

    Args:
        path: Path of the cache.

    Returns:
        The cache document, or an empty one when there is nothing to read.

    """
    path = Path(path)
    try:
        raw = path.read_text(encoding="utf-8")
    except FileNotFoundError:
        return empty_state()
    except (OSError, UnicodeError) as error:
        logger.warning("Could not read the watch cache at %s (%s); every scope will be recorded afresh.", path, error)
        return empty_state()

    try:
        document = json.loads(raw)
    except json.JSONDecodeError as error:
        logger.warning(
            "The watch cache at %s is not readable JSON (%s); every scope will be recorded afresh.", path, error
        )
        return empty_state()

    if not isinstance(document, dict) or not isinstance(document.get("scopes"), dict):
        logger.warning("The watch cache at %s is not a cache document; every scope will be recorded afresh.", path)
        return empty_state()

    version = document.get("version")
    if not isinstance(version, int) or isinstance(version, bool) or version < STATE_VERSION:
        logger.warning(
            "The watch cache at %s was written by an older version of python-gitea; every scope will be recorded "
            "afresh, and this run reports nothing. Reading it would compare comment digests taken over something "
            "else and announce every comment on every watched issue as rewritten.",
            path,
        )
        return empty_state()

    return document

gitea.watch.record_scope

record_scope(
    state: dict[str, Any],
    scope: str,
    snapshots: dict[str, dict[str, Any]],
) -> None

Replace what the cache records for one scope.

Only that scope's entry is touched, so the scopes a run did not watch - and any key of the document this version does not know about - survive the write.

Parameters:

Name Type Description Default
state dict[str, Any]

The cache document, modified in place.

required
scope str

Key of the scope.

required
snapshots dict[str, dict[str, Any]]

The snapshot of each issue currently in the scope.

required
Source code in src/gitea/watch/state.py
def record_scope(state: dict[str, Any], scope: str, snapshots: dict[str, dict[str, Any]]) -> None:
    """Replace what the cache records for one scope.

    Only that scope's entry is touched, so the scopes a run did not watch - and
    any key of the document this version does not know about - survive the write.

    Args:
        state: The cache document, modified in place.
        scope: Key of the scope.
        snapshots: The snapshot of each issue currently in the scope.

    """
    scopes = state.setdefault("scopes", {})
    scopes[scope] = {"issues": dict(snapshots)}

gitea.watch.resolve_state_path

resolve_state_path(
    state_file: str | Path | None = None,
) -> Path

Choose the cache a run reads and writes.

Parameters:

Name Type Description Default
state_file str | Path | None

The path named on the command line or by STATE_FILE_ENV, or None to use the default location.

None

Returns:

Type Description
Path

The path of the cache.

Source code in src/gitea/watch/state.py
def resolve_state_path(state_file: str | Path | None = None) -> Path:
    """Choose the cache a run reads and writes.

    Args:
        state_file: The path named on the command line or by `STATE_FILE_ENV`,
            or None to use the default location.

    Returns:
        The path of the cache.

    """
    return Path(state_file).expanduser() if state_file else default_state_path()

gitea.watch.save_scopes

save_scopes(
    path: str | Path,
    scopes: dict[str, dict[str, dict[str, Any]]],
) -> None

Record the scopes a run watched, leaving every other scope as it is.

A run is authoritative only for the scopes it was asked to watch, so the document is re-read here - immediately before it is written, rather than at the start of the run - and only those scopes are replaced in it. Writing the document the run started from would put back whatever it held then, erasing the scopes a concurrent run recorded while this one was fetching.

Re-reading is not enough on its own: two runs reaching this point together would both read the same document, and the second rename would still drop what the first recorded. The read, the change and the write are therefore one critical section held under cache_lock, so the second run reads what the first wrote. A cache that cannot be locked is still written, and is back to being racy rather than unusable.

Parameters:

Name Type Description Default
path str | Path

Path of the cache.

required
scopes dict[str, dict[str, dict[str, Any]]]

The snapshots to record, keyed by scope.

required

Raises:

Type Description
OSError

If the cache cannot be written. It is left as it was.

Source code in src/gitea/watch/state.py
def save_scopes(path: str | Path, scopes: dict[str, dict[str, dict[str, Any]]]) -> None:
    """Record the scopes a run watched, leaving every other scope as it is.

    A run is authoritative only for the scopes it was asked to watch, so the
    document is re-read here - immediately before it is written, rather than at
    the start of the run - and only those scopes are replaced in it. Writing the
    document the run started from would put back whatever it held then, erasing
    the scopes a concurrent run recorded while this one was fetching.

    Re-reading is not enough on its own: two runs reaching this point together
    would both read the same document, and the second rename would still drop
    what the first recorded. The read, the change and the write are therefore
    one critical section held under `cache_lock`, so the second run reads what
    the first wrote. A cache that cannot be locked is still written, and is back
    to being racy rather than unusable.

    Args:
        path: Path of the cache.
        scopes: The snapshots to record, keyed by scope.

    Raises:
        OSError: If the cache cannot be written. It is left as it was.

    """
    with cache_lock(path):
        state = load_state(path)
        for scope, snapshots in scopes.items():
            record_scope(state, scope, snapshots)
        save_state(path, state)

gitea.watch.save_state

save_state(path: str | Path, state: dict[str, Any]) -> None

Write the cache, so that no reader ever sees a partial document.

The document is written to a temporary file in the directory the cache lives in and renamed over it, which is atomic on every platform this runs on, and is flushed to disk first so the rename cannot publish an empty file.

Parameters:

Name Type Description Default
path str | Path

Path of the cache.

required
state dict[str, Any]

The cache document to write.

required

Raises:

Type Description
OSError

If the cache directory, the temporary file or the rename cannot be written. The cache is left as it was.

Source code in src/gitea/watch/state.py
def save_state(path: str | Path, state: dict[str, Any]) -> None:
    """Write the cache, so that no reader ever sees a partial document.

    The document is written to a temporary file in the directory the cache lives
    in and renamed over it, which is atomic on every platform this runs on, and
    is flushed to disk first so the rename cannot publish an empty file.

    Args:
        path: Path of the cache.
        state: The cache document to write.

    Raises:
        OSError: If the cache directory, the temporary file or the rename
            cannot be written. The cache is left as it was.

    """
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)

    handle, temporary = tempfile.mkstemp(dir=path.parent, prefix=f"{path.name}.", suffix=".tmp")
    try:
        with os.fdopen(handle, "w", encoding="utf-8") as file:
            json.dump({**state, "version": STATE_VERSION}, file, indent=2, sort_keys=True)
            file.flush()
            os.fsync(file.fileno())
        os.replace(temporary, path)
    finally:
        # The rename leaves nothing behind; a failure before it does.
        Path(temporary).unlink(missing_ok=True)

gitea.watch.scope_snapshots

scope_snapshots(
    state: dict[str, Any], scope: str
) -> dict[str, dict[str, Any]] | None

Read the snapshots recorded for one scope.

Parameters:

Name Type Description Default
state dict[str, Any]

The cache document.

required
scope str

Key of the scope.

required

Returns:

Type Description
dict[str, dict[str, Any]] | None

The snapshot of each issue, keyed as the cache keys them, or None when

dict[str, dict[str, Any]] | None

the scope has never been recorded - which is what baselines it rather

dict[str, dict[str, Any]] | None

than reporting every issue in it as new.

Source code in src/gitea/watch/state.py
def scope_snapshots(state: dict[str, Any], scope: str) -> dict[str, dict[str, Any]] | None:
    """Read the snapshots recorded for one scope.

    Args:
        state: The cache document.
        scope: Key of the scope.

    Returns:
        The snapshot of each issue, keyed as the cache keys them, or None when
        the scope has never been recorded - which is what baselines it rather
        than reporting every issue in it as new.

    """
    entry = state.get("scopes", {}).get(scope)
    if not isinstance(entry, dict):
        return None

    issues = entry.get("issues")
    if not isinstance(issues, dict):
        return {}

    snapshots: dict[str, dict[str, Any]] = {}
    for key, raw in issues.items():
        snapshot = _read_snapshot(raw)
        if snapshot is not None:
            snapshots[str(key)] = snapshot
    return snapshots

Modules