Hei,
Minulla on ongelma paginationissa listata Jira issueita. Ja Copilot ja ChatGPT ei osaa auttaa, koska sen näkemys on jäljessä nykyiseen API-dokumenataatioon.
https://stackoverflow.com/questions/79853409/
https://community.developer.atlassian.com/t/how-to-do-pagination-for-jira-issues/98009
Documentaatio:
Eli ei ole startAt vaan on nextPageToken ja limit. Eli requestin yhteydessä luodaan nextPageToken joka on string.
Voin maksaa auttajalle rahaakin jos hinnasta päästään sopimukseen. Ota yheyttä matti.kiviharju@i4ware.fi
Eli vielä.
NodeJS back-end:
export async function getIssues({
project = null,
year,
month,
limit = 50,
issueKeys = null,
pageToken = null,
} = {}) {
if (typeof year !== 'number' || typeof month !== 'number') {
throw new Error('getIssues requires { year: number, month: number }');
}
const safeLimit = Number(limit) && Number(limit) > 0 ? Math.min(Number(limit), 100) : 50;
const dateCondition = 'updated >= -90d OR created >= -90d';
const orderBy = 'ORDER BY created DESC';
let jql = `${dateCondition} ${orderBy}`;
if (issueKeys && Array.isArray(issueKeys) && issueKeys.length) {
const escaped = issueKeys.map(k => `"${String(k).replace(/"/g, '\\"')}"`).join(', ');
jql = `issuekey in (${escaped}) ${orderBy}`;
} else if (project && project !== '-- ALL --') {
jql = `project = "${project}" AND (${dateCondition}) ${orderBy}`;
}
console.log('getIssues JQL:', jql, 'pageToken:', pageToken, 'limit:', safeLimit);
const url = route`/rest/api/3/search/jql`;
let allIssues = [];
let cursor = pageToken || null;
try {
const body = {
expand: '',
fields: ['*all'],
fieldsByKeys: false,
jql: jql,
maxResults: safeLimit,
nextPageToken: cursor || undefined,
properties: []
};
console.log('Fetching issues with body:', JSON.stringify(body));
const res = await api.asUser().requestJira(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (res.status >= 400) {
const text = await res.text();
throw new Error(`Jira API error ${res.status}: ${text}`);
}
const json = await res.json();
const issues = (json.issues || []).map(i => ({
id: i.id,
key: i.key,
summary: i.fields.summary,
avatarUrl: i.fields.issuetype.iconUrl,
issueType: i.fields.issuetype.name,
projectKey: i.fields.project.key
}));
allIssues.push(...issues);
const nextToken = json.nextPageToken || null;
// --- Compute worklogs per issue ---
const daysInMonth = new Date(year, month + 1, 0).getDate();
const fetchForIssue = async (iss) => {
try {
const wurl = route`/rest/api/3/issue/${iss.key}/worklog`;
const wr = await api.asUser().requestJira(wurl, { method: 'GET', headers: { Accept: 'application/json' } });
if (wr.status >= 400) return { iss, hoursByDay: Array(daysInMonth).fill(0), totalHours: 0 };
const wjson = await wr.json();
const hoursByDay = Array(daysInMonth).fill(0);
(wjson.worklogs || []).forEach(w => {
const iso = normalizeIsoTimezone(w.started || '');
const d = new Date(iso);
if (!isNaN(d.getTime()) && d.getFullYear() === year && d.getMonth() === month) {
const dayIndex = d.getDate() - 1;
const secs = Number(w.timeSpentSeconds) || 0;
hoursByDay[dayIndex] += secs / 3600;
}
});
for (let i = 0; i < hoursByDay.length; i++) hoursByDay[i] = +hoursByDay[i].toFixed(2);
const totalHours = +hoursByDay.reduce((s, v) => s + v, 0).toFixed(2);
return { iss, hoursByDay, totalHours };
} catch {
return { iss, hoursByDay: Array(daysInMonth).fill(0), totalHours: 0 };
}
};
const results = await Promise.all(allIssues.map(fetchForIssue));
const issuesWithHours = results.map(r => ({
...r.iss,
hoursByDay: r.hoursByDay,
totalHours: r.totalHours
}));
return {
issues: issuesWithHours,
pageToken: cursor, // current token (used for prev)
nextPageToken: nextToken,
pageSize: safeLimit,
total: issuesWithHours.length
};
} catch (err) {
console.error('getIssues error:', err);
throw err;
}
}Ja ReactJS front-end:
const fetchIssues = useCallback(async (token = null) => {
const y = displayedMonth.getFullYear();
const m = displayedMonth.getMonth(); // 0..11
try {
setLoadingIssues(true);
// When using user's saved timesheet, pass explicit issue keys to backend
// If using my timesheet but no keys saved, return empty list immediately
if (useMyTimesheet && Array.isArray(myTimesheetKeys) && myTimesheetKeys.length === 0) {
setIssues([]);
setTotalIssues(0);
setHours({});
setLoadingIssues(false);
return;
}
const result = await invoke('getIssues', {
project: projectKey || null,
year: y,
month: m,
limit: PAGE_SIZE,
issueKeys: useMyTimesheet ? myTimesheetKeys : undefined,
pageToken: token,
});
// Accept either { issues: [...] } or [...] directly
const payload = Array.isArray(result)
? result
: (result && Array.isArray(result.issues) ? result.issues : null);
if (!payload) {
console.warn('fetchIssues: invalid response', result);
setIssues([]);
setTotalIssues(0);
return;
}
setIssues(payload);
setNextPageToken(result.nextPageToken ?? null);
if (token) {
// push current token to stack for "prev" handling
setPrevTokens(prev => [...prev, token]);
}
// If using saved personal timesheet selection, select all returned issues
if (useMyTimesheet) {
const sel = new Set();
payload.forEach(i => sel.add(i.id));
setSelectedIssues(sel);
}
// track total if provided
setTotalIssues(result?.total ?? payload.length);
// Populate hours map from backend hoursByDay data
const newHours = {};
payload.forEach((iss) => {
newHours[iss.id] = iss.hoursByDay || Array(getDaysInMonth(y, m)).fill('');
});
setHours(newHours);
} catch (error) {
console.error('Error fetching issues:', error);
showAlert('Failed to fetch issues: ' + (error.message || 'Unknown error'), 'danger');
setIssues([]);
setTotalIssues(0);
} finally {
setLoadingIssues(false);
}
}, [displayedMonth, projectKey, showAlert, useMyTimesheet, myTimesheetKeys]);
useEffect(() => {
fetchIssues(null);
}, [fetchIssues]);
const handleNextPage = () => {
if (nextPageToken) {
fetchIssues(nextPageToken);
}
};
const handlePrevPage = () => {
if (prevTokens.length > 1) {
// pop last token
const newPrev = [...prevTokens];
newPrev.pop();
const token = newPrev[newPrev.length - 1];
setPrevTokens(newPrev);
fetchIssues(token);
} else {
// first page, no previous token
fetchIssues(null);
setPrevTokens([]);
}
};
<Button
className="page-btn-prev"
variant="light"
size="sm"
onClick={handlePrevPage}
disabled={page <= 1 || loadingIssues || useMyTimesheet}
>{t('prevPage')}</Button>
<Button
className="page-btn-next"
variant="light"
size="sm"
onClick={handleNextPage}
disabled={page * PAGE_SIZE >= totalIssues || loadingIssues || useMyTimesheet}
>{t('nextPage')}</Button>