Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [Unreleased]

- `calendar list` selects `lastModifiedDateTime`, and takes `--select` to override the `$select`
list as `mail list` does.
- `meetings list --with-transcripts` adds `transcripts` to each `--json` item: the id and
`createdDateTime` of every transcript, so a consumer can pick the one nearest an occurrence of
a recurring meeting. `transcriptIds` is unchanged.
- Graph timestamps whose seconds fraction has 1, 2, 4 or 5 digits (Graph trims trailing zeros, as
in `2026-10-22T15:53:38.4Z`) parse. They used to count as undatable on Python 3.10, so
`chats list --since` kept old chats and paged on past the window edge, the other date windows
kept such items, and an event time with such a fraction stopped the command with a
`ValueError`.

## [0.4.1] — 2026-11-05

- `mail list --from` and `--to` match any of their values. Several values, repeated or
Expand Down
11 changes: 8 additions & 3 deletions skills/mgraphctl/reference/commands/calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,22 @@ are in [`../commands.md`](../commands.md).
| `--days N` | 7 | Days from `--start`, when `--end` is not given. |
| `--calendar NAME\|ID` | the default calendar | Which calendar to read. |
| `--search KW` | — | Client-side match on subject, organizer and attendees. |
| `--select a,b` | — | `$select` override. |
| `--limit N` | 50 | Maximum items. |
| `--all` | off | Fetch every page, cap 200. |
| `--json` | off | Print JSON instead of text. |

- **Graph:** `GET /me/calendarView` (or `/me/calendars/{id}/calendarView`) with
`startDateTime`, `endDateTime`,
`$select=id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink`,
`$orderby=start/dateTime`, `$top=50`, and `Prefer: outlook.timezone`.
`$select=id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,lastModifiedDateTime`
(or the `--select` list), `$orderby=start/dateTime`, `$top=50`, and `Prefer: outlook.timezone`.
- **Scopes:** `Calendars.Read`
- **Notes:** `calendarView` expands recurring series into occurrences. Columns: start, end, markers
(`T` Teams meeting, `X` cancelled), subject, organizer, location, id.
(`T` Teams meeting, `X` cancelled), subject, organizer, location, id. `lastModifiedDateTime`
changes whenever the event does, so it serves as a change fingerprint. A `--select` list must
keep the fields the text columns read (`start`, `end`, `subject`, …) or they show `N/A`;
`--json` prints whatever the list names. `--search` matches only on the fields `--select`
returns, so a list without `subject`, `organizer` and `attendees` matches nothing.

### `calendar calendars`

Expand Down
6 changes: 5 additions & 1 deletion skills/mgraphctl/reference/commands/meetings.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ All `meetings` verbs except `list` select the meeting three ways: a positional o
| `--end DT` | end of today | End of the window. |
| `--subject KW` | — | Case-insensitive subject substring. |
| `--resolve` | off | Resolve each event to its online-meeting id. |
| `--with-transcripts` | off | Also list transcript ids (implies `--resolve`). |
| `--with-transcripts` | off | Also list each transcript's id and creation time (implies `--resolve`). |
| `--limit N` | 50 | Maximum items. |
| `--json` | off | Print JSON instead of text. |

Expand All @@ -25,6 +25,10 @@ All `meetings` verbs except `list` select the meeting three ways: a positional o
- **Scopes:** `Calendars.Read`; `--resolve` additionally checks `OnlineMeetings.Read`, and
`--with-transcripts` `OnlineMeetingTranscript.Read.All`.
- **Notes:** columns: start, subject, event id, meeting id (when resolved), transcript ids.
In `--json`, each item carries `transcriptIds` and `transcripts`, a list of
`{id, createdDateTime}` as Graph returns them. Occurrences of a recurring meeting share one
online meeting, so every occurrence lists the same transcripts; `createdDateTime` tells which
one belongs to the occurrence.

### `meetings get [MEETING]`

Expand Down
5 changes: 4 additions & 1 deletion src/mgraphctl/commands/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ def list_(
search: Annotated[
str | None, typer.Option("--search", help="Client-side match on subject/attendees.")
] = None,
select: Annotated[
str | None, typer.Option("--select", help="Comma-separated $select override.")
] = None,
limit: LimitOpt = None,
all_: AllFlag = False,
json_: JsonFlag = False,
Expand All @@ -101,7 +104,7 @@ def list_(
start=start_dt,
end=end_dt,
calendar_id=calendar_id,
select=None,
select=select,
limit=limit,
all_=all_,
tz=tz,
Expand Down
15 changes: 9 additions & 6 deletions src/mgraphctl/commands/meetings.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ def list_(
] = False,
with_transcripts: Annotated[
bool,
typer.Option("--with-transcripts", help="Also list transcript ids (implies --resolve)."),
typer.Option(
"--with-transcripts",
help="Also list each transcript's id and creation time (implies --resolve).",
),
] = False,
limit: LimitOpt = None,
json_: JsonFlag = False,
Expand All @@ -99,6 +102,7 @@ def list_(
for event in events:
meeting = resolved.get(event["id"])
meeting_id = meeting.get("id") if meeting else None
found = transcripts.get(meeting_id, []) if with_transcripts and meeting_id else []
items.append(
{
"eventId": event.get("id"),
Expand All @@ -107,11 +111,10 @@ def list_(
"end": event.get("end"),
"organizer": event.get("organizer"),
"meetingId": meeting_id if do_resolve else None,
"transcriptIds": (
[t.get("id") for t in transcripts.get(meeting_id, [])]
if with_transcripts and meeting_id
else []
),
"transcriptIds": [t.get("id") for t in found],
"transcripts": [
{"id": t.get("id"), "createdDateTime": t.get("createdDateTime")} for t in found
],
}
)
return ListResult(
Expand Down
3 changes: 2 additions & 1 deletion src/mgraphctl/graph/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@

LIST_SELECT = (
"id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,"
"isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink"
"isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,"
"lastModifiedDateTime"
)
PAGE_LIST, CAP_LIST = 50, 200

Expand Down
22 changes: 16 additions & 6 deletions src/mgraphctl/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ class FileResult:

Result = ListResult | ObjectResult | TextResult | WriteResult | DryRunResult | FileResult

_FRACTIONAL_RE = re.compile(r"(\.\d{6})\d+")
_FRACTIONAL_RE = re.compile(r"(?<=\d\d:\d\d:\d\d)\.(\d+)")
_RELATIVE_RE = re.compile(r"^([+-])(\d+)([dh])$")
_DATE_ONLY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_SIMPLE_DURATION_RE = re.compile(r"^(\d+)([mhd])$")
Expand Down Expand Up @@ -333,11 +333,20 @@ def local_tz() -> str:
return "UTC"


def _six_digit_fraction(value: str) -> str:
"""Pad or cut the seconds fraction to six digits.

Graph trims trailing zeros (`.4Z`) or sends seven digits, and Python 3.10's
`datetime.fromisoformat` accepts only three or six.
"""
return _FRACTIONAL_RE.sub(lambda m: "." + m.group(1).ljust(6, "0")[:6], value)


def parse_graph_dt(value: str | None) -> datetime | None:
"""A Graph timestamp as an aware datetime (bare ones are UTC), or None when unusable."""
if not value:
return None
text = _FRACTIONAL_RE.sub(r"\1", value)
text = _six_digit_fraction(value)
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
Expand Down Expand Up @@ -369,17 +378,18 @@ def fmt_dtz(obj: dict | None, tz: str) -> str:
if date_time is None:
return "N/A"
if zone == "UTC" or is_iana(zone):
v = _FRACTIONAL_RE.sub(r"\1", date_time)
v = _six_digit_fraction(date_time)
dt = datetime.fromisoformat(v).replace(tzinfo=ZoneInfo(zone))
return dt.astimezone(ZoneInfo(tz)).isoformat(timespec="minutes")
return f"{date_time} ({zone})"


def fmt_event_time(event: dict, key: str, tz: str) -> str:
"""Render an event's start/end: all-day events as `YYYY-MM-DD (all day)`."""
if event.get("isAllDay"):
return f"{event[key]['dateTime'][:10]} (all day)"
return fmt_dtz(event[key], tz)
value = event.get(key)
if event.get("isAllDay") and value and value.get("dateTime"):
return f"{value['dateTime'][:10]} (all day)"
return fmt_dtz(value, tz)


def fmt_date(value: str | None) -> str:
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/calendar/list.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"query": {
"startDateTime": "2026-09-02T09:00:00+02:00",
"endDateTime": "2026-09-09T09:00:00+02:00",
"$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink",
"$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,lastModifiedDateTime",
"$orderby": "start/dateTime",
"$top": "50"
},
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/calendar/list_all_cap.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"query": {
"startDateTime": "2026-09-01T00:00:00+02:00",
"endDateTime": "2026-09-08T23:59:59+02:00",
"$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink",
"$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,lastModifiedDateTime",
"$orderby": "start/dateTime",
"$top": "50"
},
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/calendar/list_by_name.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"query": {
"startDateTime": "2026-09-02T09:00:00+02:00",
"endDateTime": "2026-09-09T09:00:00+02:00",
"$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink",
"$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,lastModifiedDateTime",
"$orderby": "start/dateTime",
"$top": "50"
},
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/calendar/list_cancelled.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"query": {
"startDateTime": "2026-09-02T09:00:00+02:00",
"endDateTime": "2026-09-09T09:00:00+02:00",
"$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink",
"$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,lastModifiedDateTime",
"$orderby": "start/dateTime",
"$top": "50"
},
Expand Down
32 changes: 31 additions & 1 deletion tests/test_cli_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def test_calendar_list_window_and_columns(invoke, graph, monkeypatch):
"endDateTime": "2026-09-09T09:00:00+02:00",
"$select": "id,subject,start,end,location,organizer,attendees,isOnlineMeeting,"
"onlineMeeting,isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,"
"webLink",
"webLink,lastModifiedDateTime",
"$orderby": "start/dateTime",
"$top": "50",
}
Expand All @@ -55,6 +55,36 @@ def test_calendar_list_window_and_columns(invoke, graph, monkeypatch):
assert "2026-09-03 (all day)" in r.stdout


@covers("calendar list")
def test_calendar_list_select_overrides_the_default(invoke, graph, monkeypatch):
_fix_now(monkeypatch)
route = graph.get(f"{GRAPH}/v1.0/me/calendarView").mock(
return_value=httpx.Response(
200,
json={
"value": [
{
"id": "ev-1",
"subject": "Standup",
"lastModifiedDateTime": "2026-09-01T08:00:00.4Z",
}
]
},
)
)
r = invoke("calendar", "list", "--select", "id,subject,lastModifiedDateTime", "--json")
assert r.exit_code == 0, r.stderr
assert json.loads(r.stdout)["items"][0]["lastModifiedDateTime"] == "2026-09-01T08:00:00.4Z"
params = route.calls.last.request.url.params
assert params["$select"] == "id,subject,lastModifiedDateTime"
assert params["$orderby"] == "start/dateTime"
# Text mode renders the columns --select left out as N/A.
text = invoke("calendar", "list", "--select", "id,subject,lastModifiedDateTime")
assert text.exit_code == 0, text.stderr
row = text.stdout.splitlines()[1].split()
assert row[:2] == ["N/A", "N/A"] and "Standup" in row and row[-1] == "ev-1"


@covers("calendar list")
def test_calendar_list_start_end_days_and_aliases(invoke, graph, monkeypatch):
_fix_now(monkeypatch)
Expand Down
36 changes: 36 additions & 0 deletions tests/test_cli_chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,42 @@ def test_chats_list_since_keeps_a_chat_with_no_preview_timestamp(invoke, graph):
]


@covers("chats list")
def test_chats_list_since_drops_and_stops_at_a_short_fraction_timestamp(invoke, graph):
"""A one-digit fraction (`.4Z`) still dates the chat, so it is dropped and stops the paging."""
second_page = graph.get(f"{GRAPH}/v1.0/me/chats-page-2").mock(
return_value=httpx.Response(200, json={"value": []})
)
graph.get(f"{GRAPH}/v1.0/me/chats").mock(
return_value=httpx.Response(
200,
json={
"value": [
{
"id": "19:new@thread.v2",
"chatType": "group",
"topic": "Recent",
"lastMessagePreview": {"createdDateTime": "2026-10-27T09:00:00.12Z"},
},
{
"id": "19:old@thread.v2",
"chatType": "group",
"topic": "Old",
"lastMessagePreview": {"createdDateTime": "2026-10-22T15:53:38.4Z"},
},
],
"@odata.nextLink": f"{GRAPH}/v1.0/me/chats-page-2",
},
)
)
r = invoke("chats", "list", "--since", "2026-10-26", "--all", "--json")
assert r.exit_code == 0, r.stderr
doc = json.loads(r.stdout)
assert [c["id"] for c in doc["items"]] == ["19:new@thread.v2"]
assert doc["truncated"] is False
assert not second_page.called


@covers("chats list")
def test_chats_list_since_keeps_a_chat_exactly_on_the_bound(invoke, graph):
graph.get(f"{GRAPH}/v1.0/me/chats").mock(
Expand Down
3 changes: 3 additions & 0 deletions tests/test_cli_meetings.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ def test_meetings_list_default_window_resolve_and_transcripts(invoke, graph, mon
doc = json.loads(r.stdout)
assert doc["count"] == 2 and doc["items"][0]["meetingId"] == "MSpk-1"
assert doc["items"][0]["transcriptIds"] == ["tr-1"]
assert doc["items"][0]["transcripts"] == [
{"id": "tr-1", "createdDateTime": "2026-08-28T10:05:00Z"}
]
text = invoke("meetings", "list", "--resolve", "--with-transcripts").stdout.splitlines()
assert text[0].split() == ["start", "subject", "event_id", "meeting_id", "transcripts"]

Expand Down
48 changes: 48 additions & 0 deletions tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,54 @@ def test_parse_graph_dt_treats_a_naive_timestamp_as_utc():
)


@pytest.mark.parametrize(
("value", "expected"),
[
("2026-10-22T15:53:38.4Z", datetime(2026, 10, 22, 15, 53, 38, 400000, tzinfo=timezone.utc)),
(
"2026-10-22T15:53:38.45Z",
datetime(2026, 10, 22, 15, 53, 38, 450000, tzinfo=timezone.utc),
),
(
"2026-10-22T15:53:38.4567Z",
datetime(2026, 10, 22, 15, 53, 38, 456700, tzinfo=timezone.utc),
),
(
"2026-10-22T15:53:38.45678Z",
datetime(2026, 10, 22, 15, 53, 38, 456780, tzinfo=timezone.utc),
),
(
"2026-10-22T15:53:38.1234567Z",
datetime(2026, 10, 22, 15, 53, 38, 123456, tzinfo=timezone.utc),
),
("2026-10-22T15:53:38Z", datetime(2026, 10, 22, 15, 53, 38, tzinfo=timezone.utc)),
(
"2026-10-22T15:53:38.4+03:00",
datetime(2026, 10, 22, 12, 53, 38, 400000, tzinfo=timezone.utc),
),
("2026-10-22T15:53:38.4", datetime(2026, 10, 22, 15, 53, 38, 400000, tzinfo=timezone.utc)),
],
)
def test_parse_graph_dt_accepts_any_fraction_length(value, expected):
"""Graph trims trailing zeros from the fraction; any length from one digit up parses."""
assert render.parse_graph_dt(value) == expected


def test_fraction_outside_the_seconds_is_left_alone():
assert render._six_digit_fraction("2026-10-22T15:53.5Z") == "2026-10-22T15:53.5Z"
assert (
render._six_digit_fraction("v1.2 2026-10-22T15:53:38.4Z")
== "v1.2 2026-10-22T15:53:38.400000Z"
)


def test_fmt_dtz_accepts_a_short_fraction():
assert (
render.fmt_dtz({"dateTime": "2026-08-31T08:15:00.5", "timeZone": "UTC"}, "UTC")
== "2026-08-31T08:15+00:00"
)


def test_parse_graph_dt_returns_none_for_empty_and_unparsable():
assert render.parse_graph_dt(None) is None
assert render.parse_graph_dt("") is None
Expand Down
3 changes: 2 additions & 1 deletion tests/test_sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
)
EVENT_SELECT = (
"id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,"
"isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink"
"isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink,"
"lastModifiedDateTime"
)


Expand Down
Loading