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
7 changes: 6 additions & 1 deletion backend/src/github_pm/status_report_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,12 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]:
and not n.get("isDraft")
and _updated_strictly_before_start_date(n, start_date)
]
backlog_filtered.sort(key=lambda n: int(n["number"]))
backlog_filtered.sort(
key=lambda n: (
-_calendar_days_since_update_to_end(n, end_date),
int(n["number"]),
)
)
backlog_items = [_backlog_item_from_gql_node(n, end_date) for n in backlog_filtered]

return ProjectStatusReportResponse(
Expand Down
52 changes: 52 additions & 0 deletions backend/tests/test_status_report_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,58 @@ async def override_conn():
}
]

def test_pr_backlog_sorted_by_age_descending(self, client, mock_connector_graphql):
"""PR backlog lists stalest PRs first (days_since_update), not by PR number."""
mock_connector_graphql.status_backlog_nodes = [
{
"number": 50,
"title": "Recently stale",
"url": "https://github.com/test/repo/pull/50",
"createdAt": "2025-01-01T10:00:00Z",
"updatedAt": "2025-04-03T12:00:00Z",
"state": "OPEN",
"isDraft": False,
"mergedAt": None,
"additions": 1,
"deletions": 0,
"labels": {"nodes": []},
"milestone": None,
"author": {"__typename": "User", "login": "u"},
},
{
"number": 100,
"title": "Very stale",
"url": "https://github.com/test/repo/pull/100",
"createdAt": "2025-01-01T10:00:00Z",
"updatedAt": "2025-04-01T10:00:00Z",
"state": "OPEN",
"isDraft": False,
"mergedAt": None,
"additions": 1,
"deletions": 0,
"labels": {"nodes": []},
"milestone": None,
"author": {"__typename": "User", "login": "u"},
},
]

async def override_conn():
yield mock_connector_graphql

app.dependency_overrides[connection] = override_conn
try:
r = client.get(
"/api/v1/project-status",
params={"start_date": "2025-04-04", "end_date": "2025-04-10"},
)
finally:
app.dependency_overrides.clear()

assert r.status_code == 200
backlog = r.json()["pr_backlog"]
assert [row["number"] for row in backlog] == [100, 50]
assert [row["days_since_update"] for row in backlog] == [9, 7]

def test_opened_prs_exclude_closed_without_merge(self, client):
"""PRs with GitHub state CLOSED (not merged) must not appear in opened_pull_requests."""
gitctx = MagicMock()
Expand Down
36 changes: 23 additions & 13 deletions frontend/src/utils/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ function escapeHtmlAttr(s) {
return String(s).replace(/&/g, '&').replace(/"/g, '"');
}

/**
* @param {string} s
* @returns {string}
*/
function withoutTrailingNewlines(s) {
return s.replace(/[\r\n]+$/, '');
}

/**
* @param {Record<string, unknown>} row
* @returns {string}
Expand All @@ -39,17 +47,19 @@ function statusItemAgeSuffix(row) {
* @returns {string}
*/
export function formatStatusSectionClipboardMarkdown(items) {
return (items || [])
.map((row) => {
const title = (row.title != null ? String(row.title) : '').trim();
const age = statusItemAgeSuffix(row);
const url = (row.html_url != null ? String(row.html_url) : '').trim();
if (!url) {
return `#${row.number} ${title}${age}`.trim();
}
return `[#${row.number}](${url}) ${title}${age}`.trim();
})
.join('\n');
return withoutTrailingNewlines(
(items || [])
.map((row) => {
const title = (row.title != null ? String(row.title) : '').trim();
const age = statusItemAgeSuffix(row);
const url = (row.html_url != null ? String(row.html_url) : '').trim();
if (!url) {
return `#${row.number} ${title}${age}`.trim();
}
return `[#${row.number}](${url}) ${title}${age}`.trim();
})
.join('\n')
);
}

/**
Expand All @@ -72,7 +82,7 @@ export function formatStatusSectionClipboardHtml(items) {
const href = escapeHtmlAttr(url);
return `<a href="${href}">#${row.number}</a> ${title}${age}`;
})
.join('<br />\n');
.join('<br />');
}

/**
Expand All @@ -98,7 +108,7 @@ export async function copyStatusSectionToClipboard(items) {
return;
}
const innerHtml = formatStatusSectionClipboardHtml(items);
const htmlDoc = `<!DOCTYPE html><html><body><meta charset="utf-8"><div>${innerHtml}</div></body></html>`;
const htmlDoc = `<!DOCTYPE html><html><head><meta charset="utf-8" /></head><body>${innerHtml}</body></html>`;

if (
typeof navigator !== 'undefined' &&
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/utils/clipboard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ describe('formatStatusSectionClipboardMarkdown', () => {
expect(formatStatusSectionClipboardMarkdown(null)).toBe('');
});

it('does not end with a trailing newline', () => {
const text = formatStatusSectionClipboardMarkdown([
{
number: 42,
title: 'Hello world',
html_url: 'https://github.com/o/r/pull/42',
},
{
number: 7,
title: 'Second',
html_url: 'https://github.com/o/r/issues/7',
},
]);
expect(text.endsWith('\n')).toBe(false);
expect(text.endsWith('\r')).toBe(false);
});

it('appends age suffix for PR backlog rows with days_since_update', () => {
expect(
formatStatusSectionClipboardMarkdown([
Expand Down Expand Up @@ -79,6 +96,14 @@ describe('formatStatusSectionClipboardHtml', () => {
]);
expect(html).toContain('T (3 days)');
});

it('does not end with a trailing newline', () => {
const html = formatStatusSectionClipboardHtml([
{ number: 1, title: 'A', html_url: 'https://github.com/o/r/pull/1' },
{ number: 2, title: 'B', html_url: 'https://github.com/o/r/pull/2' },
]);
expect(html.endsWith('\n')).toBe(false);
});
});

describe('copyTextToClipboard', () => {
Expand Down
Loading